======================================================================== EVOLUTION IA — CONTEXTE RÉEL DE L'APPEL IA ======================================================================== Appel : #864 Provider : openai Rôle : development_engine Phase : final Modèle demandé : gpt-5.6-terra Tokens entrée fournisseur : 22822 Tokens total fournisseur : 22940 NOTE : copie capturée juste avant l'appel HTTP. Les secrets sont expurgés. ======================================================================== [1] INSTRUCTIONS / SYSTEM PROMPT ======================================================================== Tu es un moteur de développement logiciel. Analyse le projet local, demande les autorisations nécessaires avant de lire ou modifier les fichiers, puis propose des changements précis, réversibles et vérifiables. --- === BEGIN CONTEXT PAYLOAD INSPECTOR === Payload final: 86005 octets | sha256=86f1396d5c4896edbda7b3b099efb20dfceeca6ea596042ba30c6311b3070d00 [OK] app/Services/DevelopmentCodeExplorerService.php | 83944 octets === END CONTEXT PAYLOAD INSPECTOR === === CONTEXTE FINAL VERROUILLE evolution — SOURCE DE VERITE === v2.4.7 : tout chemin exact explicite et resolu est promu OBLIGATOIRE, meme s'il avait deja ete decouvert. Les blocs PINNED sont injectes entiers et jamais tronques. Ces fichiers viennent d'etre relus sur le disque juste avant la generation du diff. Les marqueurs COMPLET signifient que le contenu entre BEGIN/END est integral. PLAN D'EXPLORATION EXECUTE PAR evolution : - path: app/Services/DevelopmentCodeExplorerService.php => 1 nouveau(x) fichier(s), cible exacte promue OBLIGATOIRE - PIN path obligatoire -> app/Services/DevelopmentCodeExplorerService.php === CONTEXTE OBLIGATOIRE — CHEMINS EXACTS AUTORISES === --- BEGIN PINNED app/Services/DevelopmentCodeExplorerService.php | 83944 octets | sha256=058ae1dfc15a62a08526b853bd549d24d92eeaa9dfca40ae35e99a0db1809559 | COMPLET --- Controller -> méthode -> View -> assets. */ class DevelopmentCodeExplorerService { private const MAX_FILES = 10; private const MAX_SNIPPET = 30000; private const MAX_TARGET_CONTENT = 70000; private const MAX_PINNED_FILE = 220000; private const MAX_PINNED_CONTEXT = 260000; private const MAX_SECONDARY_SNIPPET = 14000; private const MAX_CONTEXT = 180000; private const MAX_FILE_SIZE = 900000; private array $roots = ['app', 'public/assets']; private array $extensions = ['php', 'js', 'css', 'json', 'md']; private string $projectRoot; public function __construct(?string $projectId=null) { $project=(new DevelopmentProjectManagerService())->resolve($projectId?:'system'); $this->projectRoot=rtrim((string)$project['root'],'\\/').DIRECTORY_SEPARATOR; if(($project['type']??'')==='codeigniter3') $this->roots=['application','assets']; } public function explore(string $request, ?string $sourceUri = null): string { $terms = $this->terms($request); $sourceUri = trim((string) $sourceUri, '/'); if (!$terms && $sourceUri === '') { return ''; } $root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR); $selected = []; $contents = []; $chain = []; $forcedSnippets = []; // 1. Routes : toujours examinées en premier, car elles permettent de // remonter proprement vers le contrôleur même si son nom n'apparaît pas // dans la demande utilisateur. $routesPath = 'app/Config/Routes.php'; $routesContent = $this->readRelative($root, $routesPath); if ($routesContent !== null) { // La page depuis laquelle le Development Engine est appelé est un // ancrage fiable, même si la demande est vague (ex. "trouve tout seul"). // Priorité à la cible exprimée dans la demande. L'URI source n'est // qu'un filet de sécurité pour les demandes vagues portant sur la page courante. $routeMatches = $this->findMatchingRoutes($routesContent, $terms); if(!$routeMatches && $sourceUri !== '') { $routeMatches = $this->findRoutesByUri($routesContent, $sourceUri); } // Dédupliquer les routes retenues. foreach ([] as $candidate) { $key = strtolower($candidate['verb'].'|'.$candidate['uri'].'|'.$candidate['controller'].'|'.$candidate['method']); $exists = false; foreach ($routeMatches as $existing) { $existingKey = strtolower($existing['verb'].'|'.$existing['uri'].'|'.$existing['controller'].'|'.$existing['method']); if ($existingKey === $key) { $exists = true; break; } } if (!$exists) { $routeMatches[] = $candidate; } } if ($routeMatches) { $this->addFile($selected, $contents, $routesPath, $routesContent); foreach ($routeMatches as $route) { $chain[] = sprintf( 'Route %s %s -> %s::%s()', strtoupper($route['verb']), $route['uri'], $route['controller'], $route['method'] ); // 2. Contrôleur réellement pointé par la route. $controllerPath = $this->resolveControllerPath($root, $route['controller']); $controllerContent = $this->readRelative($root, $controllerPath); if ($controllerContent === null) { continue; } $this->addFile($selected, $contents, $controllerPath, $controllerContent); // 3. Méthode ciblée : extraire en priorité son bloc pour // identifier les vues sans dépendre du score de recherche. $methodBlock = $this->extractMethodBlock($controllerContent, $route['method']); // Pour les propositions, fournir le contrôleur réel (pas seulement // le dernier bloc de méthode rencontré). Plusieurs routes peuvent pointer // vers le même contrôleur et écrasaient auparavant index() avec finalize(). $forcedSnippets[$controllerPath] = $controllerContent; $views = $this->extractViews($methodBlock ?: $controllerContent); foreach ($views as $view) { // Les vues dynamiques ne sont pas résolues ici : on ne // doit jamais inventer leur chemin. if (str_contains($view, '$') || str_contains($view, '{')) { continue; } $viewPath = 'app/Views/' . trim($view, '/') . '.php'; $viewContent = $this->readRelative($root, $viewPath); if ($viewContent === null) { continue; } $chain[] = sprintf('%s::%s() -> vue %s', $route['controller'], $route['method'], $viewPath); $this->addFile($selected, $contents, $viewPath, $viewContent); $forcedSnippets[$viewPath] = $viewContent; // 4. Assets explicitement référencés par la vue. foreach ($this->extractAssetPaths($viewContent) as $assetPath) { $assetContent = $this->readRelative($root, $assetPath); if ($assetContent !== null) { $chain[] = sprintf('Vue %s -> asset %s', $viewPath, $assetPath); $this->addFile($selected, $contents, $assetPath, $assetContent); } } // 5. Classes CSS spécifiques de la vue : chercher leurs // définitions dans les feuilles locales connues. foreach ($this->findCssForView($root, $viewContent) as $cssPath => $cssContent) { $chain[] = sprintf('Styles spécifiques détectés -> %s', $cssPath); $this->addFile($selected, $contents, $cssPath, $cssContent); } } } } } // 6. Exploration autonome multi-étapes : une demande d'interface contient // souvent un libellé visible (ex. "Administration") sans URL ni nom de // contrôleur. On recherche d'abord ces expressions littérales dans le code // réel, puis on suit les références de vues/layouts/assets trouvées. foreach ($this->literalMatches($root, $request) as $path => $content) { if (count($selected) >= self::MAX_FILES) { break; } $this->addFile($selected, $contents, $path, $content); $forcedSnippets[$path] = $content; $chain[] = 'Texte/interface localisé automatiquement -> ' . $path; $this->followFileReferences($root, $path, $content, $selected, $contents, $forcedSnippets, $chain); } // 7. Recherche textuelle générale en complément de la chaîne résolue. // Elle sert pour les services, modèles, JS ou CSS non explicitement liés. $scores = $this->scoreProjectFiles($root, $terms); foreach (array_keys($scores) as $path) { if (count($selected) >= self::MAX_FILES) { break; } if (isset($contents[$path])) { continue; } $content = $this->readRelative($root, $path); if ($content !== null) { $this->addFile($selected, $contents, $path, $content); } } if (!$selected) { return ''; } $out = "\n\n=== CODE EXPLORER evolution — LECTURE SEULE ===\n"; $out .= "Les informations suivantes proviennent du code réellement présent sur le serveur. " . "Utilise-les comme source de vérité. Ne prétends jamais avoir lu un fichier absent de cette section.\n"; if ($chain) { $out .= "\nCHAÎNE TECHNIQUE RÉSOLUE AUTOMATIQUEMENT :\n"; foreach (array_values(array_unique($chain)) as $step) { $out .= "- {$step}\n"; } } $out .= "\nFICHIERS LUS AUTOMATIQUEMENT :\n"; foreach (array_slice($selected, 0, self::MAX_FILES) as $path) { $content = $contents[$path] ?? ''; $out .= "\n--- {$path} ---\n"; if (isset($forcedSnippets[$path])) { $out .= $this->targetContent($forcedSnippets[$path]) . "\n"; } else { $out .= $this->snippet($content, $terms) . "\n"; } if (strlen($out) >= self::MAX_CONTEXT) { break; } } $out .= "\nRÈGLE D'UTILISATION : si la chaîne Route -> Controller -> méthode -> View est résolue ci-dessus, " . "ne demande pas à l'administrateur de fournir manuellement ces fichiers. Analyse directement les contenus fournis. Les fichiers marqués comme cibles déterministes sont transmis intégralement jusqu'à 70 Ko ; ne les considère pas comme tronqués s'ils ne portent pas le marqueur d'omission evolution.\n"; return AiTextEncodingService::slice($out, 0, self::MAX_CONTEXT); } /** * Pré-résolution sémantique des demandes d'interface. * * Elle ne donne aucun droit d'écriture : elle transforme uniquement une * intention UI (apparence, boutons, thème, réglages, layout...) en cibles * techniques vérifiées sur le disque. Le but est d'éviter d'attendre que * le modèle redemande des fichiers évidents au premier cycle. */ public function inferSemanticDependencies(string $request): array { $root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR); $lower = $this->ascii(mb_strtolower($request)); $isUi = preg_match('/\b(apparence|bouton|buttons?|theme|couleur|style|css|interface|layout|sidebar|menu|formulaire|bordure|radius|arrondi|admin)\b/', $lower) === 1; $isSettings = preg_match('/\b(reglage|reglages|settings?|parametre|parametres|apparence|theme)\b/', $lower) === 1; $isDevelopmentUi = preg_match('/\b(evolution ia|evolution-ia|development engine|development|moteur de developpement|conversation technique|code explorer|controleur de streaming|controller de streaming|streaming)\b/', $lower) === 1; if (!$isUi && !$isSettings && !$isDevelopmentUi) return []; $hints = []; $seen = []; $add = static function(array $hint) use (&$hints, &$seen): void { $key = strtolower(($hint['type'] ?? '').'|'.($hint['value'] ?? '')); if ($key === '|' || isset($seen[$key])) return; $seen[$key] = true; $hints[] = $hint; }; $addPath = function(string $path) use ($root, $add): void { if ($this->readRelative($root, $path) !== null) $add(['type'=>'path','value'=>$path]); }; // Une demande explicitement limitée à l'administration doit toujours // connaître le layout admin et ses assets locaux avant finalisation. if (str_contains($lower, 'admin')) { $addPath('app/Views/layouts/admin.php'); $layout = $this->readRelative($root, 'app/Views/layouts/admin.php'); if ($layout !== null) { foreach ($this->extractAssetPaths($layout) as $assetPath) $addPath($assetPath); } } // v2.3.5 — l'interface propre à Évolution IA est une cible de lecture // de premier ordre. Lorsqu'une demande concerne le Development Engine, // on expose directement sa vue principale, son contrôleur, son layout et // ses scripts locaux avant toute finalisation. if ($isDevelopmentUi) { foreach ([ 'app/Views/admin/development/index.php', 'app/Controllers/Admin/Development.php', 'app/Views/layouts/admin.php', 'public/assets/js/development-engine.js', 'public/assets/js/development-voice.js', ] as $path) $addPath($path); $routes = $this->readRelative($root, 'app/Config/Routes.php'); if ($routes !== null && $this->findRoutesByUri($routes, 'admin/evolution-ia')) { $add(['type'=>'route','value'=>'admin/evolution-ia']); } } // Apparence/réglages : suivre la vraie route si elle existe, puis le // système de settings et ses migrations. Toutes les cibles sont ensuite // résolues par les mécanismes path/symbol/table existants. if ($isSettings) { $routes = $this->readRelative($root, 'app/Config/Routes.php'); if ($routes !== null) { foreach (['admin/reglages', 'reglages'] as $uri) { if ($this->findRoutesByUri($routes, $uri)) { $add(['type'=>'route','value'=>$uri]); break; } } } foreach (['app/Controllers/Admin/Settings.php','app/Views/admin/settings.php'] as $path) $addPath($path); foreach (['SettingService','SettingModel'] as $symbol) { if ($this->resolveSymbolPaths($root, $symbol)) $add(['type'=>'symbol','value'=>$symbol]); } if ($this->resolveTablePaths($root, 'settings')) $add(['type'=>'table','value'=>'settings']); } // Si l'UI admin est ciblée mais le mot réglages n'est pas écrit, les // styles locaux du layout restent nécessaires pour décider où appliquer // les variables sans toucher à l'espace membre. if ($isUi && str_contains($lower, 'admin')) { foreach (['public/assets/css/evolution.css','public/assets/css/evolution-theme.css'] as $path) $addPath($path); } return array_slice($hints, 0, 16); } /** * Convertit une intention de poursuite exprimée en langage naturel * (ex. "explorer la partie publique du diaporama") en dépendances * techniques réelles. Aucune cible n'est inventée : seules les routes, * symboles et fichiers effectivement présents sur disque sont retournés. */ public function inferDeferredDependencies(string $request, string $summary, array $alreadySeen = []): array { $root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR); $text = trim($request.' '.$summary); if ($text === '') return []; $hints = []; $seenKeys = []; foreach ($alreadySeen as $hint) { $seenKeys[is_array($hint) ? json_encode($hint) : 's:'.(string)$hint] = true; } $add = static function(array $hint) use (&$hints, &$seenKeys): void { $key = json_encode($hint); if (isset($seenKeys[$key])) return; $seenKeys[$key] = true; $hints[] = $hint; }; // v2.3.1 : si le Finalizer écrit déjà un plan structuré dans sa prose, // l'exécuter tel quel. Cela couvre notamment "symbol: Library", // "schema: media", "migration: media" et "search: csrf_hash". if (preg_match_all('/^\s*(?:[-*]\s*)?(path|symbol|method|route|table|schema|migration|backup|install|rollback|search|service)\s*:\s*(.+?)\s*$/mi', $summary, $structured, PREG_SET_ORDER)) { foreach ($structured as $row) { $type = strtolower(trim((string) $row[1])); $value = trim((string) $row[2], " \t\n\r\0\x0B`"); if ($type === 'path') $value = trim(str_replace('\\\\', '/', $value), '/'); if ($value === '') continue; if ($type === 'path' && $this->readRelative($root, $value) === null) continue; $add(['type'=>$type,'value'=>$value]); } } // v2.3.2 — normalisation du langage naturel en plan d'exploration. // Le Finalizer n'est pas obligé d'émettre les préfixes path:/schema:/... // pour que evolution exécute une demande explicite. Les formulations restent // volontairement bornées et déterministes : chemins existants, tables // nommées et termes de recherche littéraux réellement cités. $natural = $this->normalizeNaturalExplorationPlan($summary); foreach ($natural as $hint) $add($hint); // v2.2.8 : une formulation tardive comme « le Code Explorer doit lire » // reste une dépendance exécutable, jamais une tâche pour l'administrateur. // 1) Tout chemin explicite réellement présent reste prioritaire. if (preg_match_all('~(?:app|public/assets)/[A-Za-z0-9_./-]+(?:\.php|\.js|\.css|\.json|\.md)?~i', $summary, $m)) { foreach ($m[0] as $path) { $path = trim(str_replace('\\', '/', $path), '/'); if ($this->readRelative($root, $path) !== null) $add(['type'=>'path','value'=>$path]); } } // 2) Pour une demande "publique / membre / familiale", résoudre les // routes non-admin correspondant au sujet réel de la demande. $lower = mb_strtolower($summary); $publicIntent = preg_match('/\b(public(?:que)?|membre|familial(?:e)?|front(?:end)?|côté membre|cote membre)\b/u', $lower) === 1; $routesContent = $this->readRelative($root, 'app/Config/Routes.php'); if ($routesContent !== null) { $terms = $this->terms($text); $routes = $this->routeDefinitions($routesContent); $ranked = []; foreach ($routes as $route) { $uri = trim((string)($route['uri'] ?? ''), '/'); $controller = (string)($route['controller'] ?? ''); if ($publicIntent && (str_starts_with($uri, 'admin/') || str_contains($controller, '\\Admin\\') || $uri === 'admin')) continue; $haystack = $this->ascii(mb_strtolower($uri.' '.$controller.'::'.(string)($route['method'] ?? ''))); $score = 0; foreach ($terms as $term) { $needle = $this->ascii($term); if ($needle !== '' && str_contains($haystack, $needle)) $score += 5; } if ($score > 0) { $route['score'] = $score; $ranked[] = $route; } } usort($ranked, static fn(array $a,array $b): int => ($b['score']??0) <=> ($a['score']??0)); foreach (array_slice($ranked,0,6) as $route) { $uri = trim((string)($route['uri'] ?? ''), '/'); if ($uri !== '') $add(['type'=>'route','value'=>$uri]); } } // 3) Classes techniques explicitement nommées dans l'explication. if (preg_match_all('/\b[A-Z][A-Za-z0-9_]*(?:Model|Service|Controller)\b/', $summary, $m)) { foreach ($m[0] as $symbol) { if ($this->resolveSymbolPaths($root, $symbol)) $add(['type'=>'symbol','value'=>$symbol]); } } // 4) Si l'intention est publique et aucune route n'a été trouvée par le // scoring, chercher un contrôleur non-admin dont le nom correspond à un // terme fonctionnel présent dans la demande (ex. Slideshow/diaporama). if ($publicIntent && !$hints) { foreach ($this->terms($text) as $term) { if (mb_strlen($term) < 5) continue; foreach ($this->resolveSymbolPaths($root, ucfirst($term).'Controller') as $path) { $add(['type'=>'path','value'=>$path]); if (count($hints) >= 4) break 2; } } } // v2.2.9 — une demande tardive de recherche textuelle devient une // opération du Code Explorer. On ne transforme que les termes réellement // cités dans summary afin d'éviter les recherches génériques inventées. foreach ($this->extractRequestedSearchTerms($summary) as $term) { $add(['type'=>'search','value'=>$term]); } if (preg_match('/(service|services).{0,80}(fichier|fichiers|upload|uploads|media|stockage|storage)/iu', $summary) || preg_match('/(fichier|fichiers|upload|uploads|media|stockage|storage).{0,80}(service|services)/iu', $summary)) { $add(['type'=>'service','value'=>'file-management']); } return array_slice($hints, 0, 12); } /** * Résout les besoins structurels exprimés par l'IA : schéma de tables, * migrations existantes et cycle backup/install/rollback. Ces dépendances * sont converties en cibles déterministes ; aucune écriture n'est réalisée. */ public function inferStructuralDependencies(string $request,string $summary,array $alreadySeen=[]): array { $root=rtrim($this->projectRoot,DIRECTORY_SEPARATOR); $text=trim($request.' '.$summary); if($text==='') return []; $lower=$this->ascii(mb_strtolower($text)); $hints=[];$seen=[]; foreach($alreadySeen as $hint){ if(is_array($hint)) $seen[strtolower((string)($hint['type']??'').'|'.(string)($hint['value']??''))]=true; } $add=static function(string $type,string $value) use (&$hints,&$seen): void { $value=trim($value); if($value==='') return; $key=strtolower($type.'|'.$value); if(isset($seen[$key])) return; $seen[$key]=true; $hints[]=['type'=>$type,'value'=>$value]; }; // Fichiers/classes explicitement nommés. if(preg_match_all('~(?:app|public/assets)/[A-Za-z0-9_./-]+(?:\\.php|\\.js|\\.css|\\.json|\\.md)?~i',$summary,$m)){ foreach($m[0] as $path){$path=trim(str_replace('\\\\','/',$path),'/');if($this->readRelative($root,$path)!==null)$add('path',$path);} } if(preg_match_all('/\b[A-Z][A-Za-z0-9_]*(?:Model|Service|Controller)\b/',$summary,$m)){ foreach($m[0] as $symbol){ if(!$this->resolveSymbolPaths($root,$symbol)) continue; $add('symbol',$symbol); if(str_ends_with($symbol,'Model')){ $table=$this->resolveModelTable($root,$symbol); if($table!==null){ $add('schema',$table); $add('migration',$table); } } } } // Tables explicitement nommées : dev_xxx, noms entre backticks, ou // mentions proches de "table / colonnes / schéma / migration". $tables=[]; if(preg_match_all('/`([a-z][a-z0-9_]{2,})`/i',$summary,$m)) $tables=array_merge($tables,$m[1]); if(preg_match_all('/\\b(dev_[a-z0-9_]+|[a-z][a-z0-9_]*_[a-z0-9_]+)\\b/i',$summary,$m)) $tables=array_merge($tables,$m[1]); // Tables simples comme `media` : uniquement dans une formulation // structurelle explicite pour éviter les faux positifs. if(preg_match_all('/\btable\s+[`"\']?([a-z][a-z0-9_]{2,})[`"\']?/ui',$text,$m)) $tables=array_merge($tables,$m[1]); if(preg_match_all('/\b(?:table|schema|schéma|colonnes?|columns?|index|indexes|migration(?:s)?)(?:\s+(?:sql|réel|reel|exacte?|existantes?)){0,3}\s+(?:de|du|pour|sur|concernant)\s+[`"\']?([a-z][a-z0-9_]{2,})[`"\']?/ui',$text,$m)) $tables=array_merge($tables,$m[1]); $tables=array_values(array_unique(array_map('strtolower',$tables))); $wantsSchema=preg_match('/\\b(schema|schéma|structure|colonnes?|columns?|index|indexes)\\b/ui',$text)===1; $wantsMigration=preg_match('/\\b(migration|migrations|forge)\\b/ui',$text)===1; foreach($tables as $table){ // v2.3.5 — les candidats issus du texte libre (notamment les mots // avec underscore) doivent correspondre à une table réellement // connectée avant de devenir une dépendance BDD. if(!$this->isExistingDatabaseTable($table)) continue; if($wantsSchema) $add('schema',$table); else $add('table',$table); if($wantsMigration) $add('migration',$table); } // Cycle d'installation / sauvegarde / rollback. if(preg_match('/\\b(backup|backups|sauvegarde|sauvegardes)\\b/ui',$text)) $add('backup','development'); if(preg_match('/\\b(install|installation|installer|migration runner|migrations)\\b/ui',$text)) $add('install','development'); if(preg_match('/\\b(rollback|restore|restauration|restaurer)\\b/ui',$text)) $add('rollback','development'); // La demande du "début complet" d'un service signifie qu'il faut le // relire intégralement : symbol/path le garantit dans le contexte pinned. if(str_contains($lower,'developmentproposalservice') && $this->readRelative($root,'app/Services/DevelopmentProposalService.php')!==null){ $add('path','app/Services/DevelopmentProposalService.php'); } return array_slice($hints,0,16); } /** * Valide uniquement les noms de tables déduits depuis du texte libre. * Les dépendances structurées explicites restent conservées par leur * protocole ; cette vérification évite surtout les faux positifs comme * needs_context ou migration_path. */ private function isExistingDatabaseTable(string $table): bool { static $available=null; $table=preg_replace('/[^A-Za-z0-9_]/','',$table); if($table==='') return false; if($available===null){ $available=[]; try{ foreach(db_connect()->listTables() as $name) $available[strtolower((string)$name)]=true; }catch(\Throwable){ return false; } } return isset($available[strtolower($table)]); } /** * Expansion ciblée demandée par le moteur de proposition. * Les indices viennent d'une première analyse IA, mais toutes les lectures * sont résolues et vérifiées localement avant d'être injectées. */ public function exploreDependencies(string $request, array $hints): string { $root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR); $selected = []; $contents = []; $chain = []; $normalized = $this->normalizeDependencyHints($hints); if (!$normalized) return ''; // Résolution déterministe et prioritaire : les dépendances explicites sont // épinglées avant toute recherche textuelle. Une phrase IA n'est jamais // transformée aveuglément en dizaines de termes génériques. foreach ($normalized as $hint) { $type = $hint['type']; $value = $hint['value']; if ($type === 'path') { $this->pinPath($root, $value, $selected, $contents, $chain, 'Chemin exact'); } elseif ($type === 'symbol') { foreach ($this->resolveSymbolPaths($root, $value) as $path) { $this->pinPath($root, $path, $selected, $contents, $chain, 'Symbole '.$value); } } elseif ($type === 'method') { foreach ($this->resolveMethodPaths($root, $value) as $path) { $this->pinPath($root, $path, $selected, $contents, $chain, 'Méthode '.$value); } } elseif ($type === 'route') { $routes = $this->readRelative($root, 'app/Config/Routes.php'); if ($routes !== null) { $this->pinPath($root, 'app/Config/Routes.php', $selected, $contents, $chain, 'Route '.$value); foreach ($this->findRoutesByUri($routes, trim($value, '/')) as $route) { $controllerPath = $this->resolveControllerPath($root, $route['controller']); $this->pinPath($root, $controllerPath, $selected, $contents, $chain, 'Contrôleur de route '.$value); $controller = $contents[$controllerPath] ?? ''; $block = $this->extractMethodBlock($controller, $route['method']); foreach ($this->extractViews($block ?: $controller) as $view) { if (!str_contains($view, '$') && !str_contains($view, '{')) { $this->pinPath($root, 'app/Views/'.trim($view, '/').'.php', $selected, $contents, $chain, 'Vue de route '.$value); } } } } } elseif (in_array($type,['table','schema','migration'],true)) { foreach ($this->resolveTablePaths($root, $value) as $path) { $this->pinPath($root, $path, $selected, $contents, $chain, strtoupper($type).' '.$value); } } elseif (in_array($type,['backup','install','rollback'],true)) { foreach ($this->resolveLifecyclePaths($root,$type,$value) as $path) { $this->pinPath($root,$path,$selected,$contents,$chain,strtoupper($type).' '.$value); } } elseif ($type === 'search') { foreach ($this->resolveSearchPaths($root,$value) as $path) { $this->pinPath($root,$path,$selected,$contents,$chain,'Recherche exacte '.$value); } } elseif ($type === 'service') { foreach ($this->resolveServicePaths($root,$value) as $path) { $this->pinPath($root,$path,$selected,$contents,$chain,'Service '.$value); } } if (count($selected) >= 14) break; } if (!$selected) return ''; $out = "\n\n=== EXPANSION STRUCTURÉE DES DÉPENDANCES — LECTURE SEULE ===\n"; $out .= "Les fichiers ci-dessous ont été résolus par chemin, symbole, méthode, route ou table réels. Ils sont prioritaires.\n"; foreach (array_values(array_unique($chain)) as $step) $out .= '- '.$step."\n"; $out .= "\nFICHIERS DE DÉPENDANCES PRIORITAIRES :\n"; foreach ($selected as $path) { $out .= "\n--- {$path} ---\n".$this->targetContent($contents[$path] ?? '')."\n"; if (strlen($out) >= 260000) break; } $out .= "\nRÈGLE : ces dépendances sont déjà lues depuis le disque réel. Ne demande pas une nouvelle exploration d'un élément présent ci-dessus.\n"; return AiTextEncodingService::slice($out, 0, 260000); } /** * Contexte final verrouillé pour la génération du diff. * Contrairement au contexte exploratoire, les dépendances explicites sont * relues juste avant l'appel final et ne passent ni par snippet() ni par la * limite globale de 180 Ko. Chaque fichier est accompagné de sa taille et * d'un marqueur COMPLET vérifiable. */ public function explorePinnedDependencies(string $request, array $hints): string { $root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR); $selected = []; $contents = []; $chain = []; $executed = []; $normalized = $this->normalizeDependencyHints($hints); if (!$normalized) return ''; // v2.4.5 — les chemins exacts demandés/autorisés sont obligatoires. // Ils sont lus et placés AVANT toutes les dépendances secondaires. $mandatoryHints = []; $secondaryHints = []; foreach ($normalized as $hint) { if (($hint['type'] ?? '') === 'path') $mandatoryHints[] = $hint; else $secondaryHints[] = $hint; } $mandatoryPaths = []; foreach ($mandatoryHints as $hint) { $value = (string)($hint['value'] ?? ''); $before = count($selected); $this->pinPath($root, $value, $selected, $contents, $chain, 'PIN path obligatoire'); // v2.4.7 — un path explicite est obligatoire même si le fichier // avait déjà été découvert par une dépendance précédente. La // v2.4.5 ne promouvait que le dernier fichier *nouvellement* // ajouté à $selected, ce qui rendait l'obligation dépendante de // l'ordre d'exploration. $exactPath = trim(str_replace('\\', '/', $value), '/'); if ($exactPath !== '' && array_key_exists($exactPath, $contents)) { $mandatoryPaths[$exactPath] = true; } $executed[] = 'path: '.$value.' => '.max(0, count($selected) - $before). ' nouveau(x) fichier(s), '.(isset($mandatoryPaths[$exactPath]) ? 'cible exacte promue OBLIGATOIRE' : 'aucun fichier exact résolu'); } foreach ($secondaryHints as $hint) { $type=$hint['type']; $value=$hint['value']; $before=count($selected); if ($type==='symbol') { foreach($this->resolveSymbolPaths($root,$value) as $path) $this->pinPath($root,$path,$selected,$contents,$chain,'PIN symbol '.$value); } elseif ($type==='method') { foreach($this->resolveMethodPaths($root,$value) as $path) $this->pinPath($root,$path,$selected,$contents,$chain,'PIN method '.$value); } elseif ($type==='route') { $routes=$this->readRelative($root,'app/Config/Routes.php'); if($routes!==null){ $this->pinPath($root,'app/Config/Routes.php',$selected,$contents,$chain,'PIN route '.$value); foreach($this->findRoutesByUri($routes,trim($value,'/')) as $route){ $controllerPath=$this->resolveControllerPath($root,$route['controller']); $this->pinPath($root,$controllerPath,$selected,$contents,$chain,'PIN controller route '.$value); $controller=$contents[$controllerPath]??''; $block=$this->extractMethodBlock($controller,$route['method']); foreach($this->extractViews($block?:$controller) as $view){ if(!str_contains($view,'$')&&!str_contains($view,'{')) $this->pinPath($root,'app/Views/'.trim($view,'/').'.php',$selected,$contents,$chain,'PIN view route '.$value); } } } } elseif (in_array($type,['table','schema','migration'],true)) { foreach($this->resolveTablePaths($root,$value) as $path) $this->pinPath($root,$path,$selected,$contents,$chain,'PIN '.$type.' '.$value); } elseif (in_array($type,['backup','install','rollback'],true)) { foreach($this->resolveLifecyclePaths($root,$type,$value) as $path) $this->pinPath($root,$path,$selected,$contents,$chain,'PIN '.$type.' '.$value); } elseif ($type==='search') { foreach($this->resolveSearchPaths($root,$value) as $path) $this->pinPath($root,$path,$selected,$contents,$chain,'PIN search '.$value); } elseif ($type==='service') { foreach($this->resolveServicePaths($root,$value) as $path) $this->pinPath($root,$path,$selected,$contents,$chain,'PIN service '.$value); } $executed[]=$type.': '.$value.' => '.max(0,count($selected)-$before).' nouveau(x) fichier(s) épinglé(s)'; } if(!$selected) return ''; $out="\n\n=== CONTEXTE FINAL VERROUILLE evolution — SOURCE DE VERITE ===\n"; $out.="v2.4.7 : tout chemin exact explicite et resolu est promu OBLIGATOIRE, meme s'il avait deja ete decouvert. Les blocs PINNED sont injectes entiers et jamais tronques.\n"; $out.="Ces fichiers viennent d'etre relus sur le disque juste avant la generation du diff. Les marqueurs COMPLET signifient que le contenu entre BEGIN/END est integral.\n"; $out.="\nPLAN D'EXPLORATION EXECUTE PAR evolution :\n"; foreach(array_values(array_unique($executed)) as $step) $out.='- '.$step."\n"; foreach(array_values(array_unique($chain)) as $step) $out.='- '.$step."\n"; $footer="\nREGLE FINALE : ne demande jamais de relire un fichier marque COMPLET ci-dessus. Produis maintenant files[]/database ou indique uniquement une dependance concrete absente.\n"; $omitted=[]; // 1) Contexte obligatoire : tous les path explicites doivent tenir EN ENTIER. if ($mandatoryPaths) { $out.="\n=== CONTEXTE OBLIGATOIRE — CHEMINS EXACTS AUTORISES ===\n"; } foreach(array_keys($mandatoryPaths) as $path){ $content=$contents[$path]??''; $size=strlen($content); if($size>self::MAX_PINNED_FILE){ throw new \RuntimeException( 'Code Explorer : fichier obligatoire trop volumineux pour la garantie COMPLET : '. $path.' ('.$size.' octets, limite '.self::MAX_PINNED_FILE.').' ); } $hash=hash('sha256',$content); $block="\n--- BEGIN PINNED {$path} | {$size} octets | sha256={$hash} | COMPLET ---\n".$content."\n--- END PINNED {$path} | COMPLET ---\n"; $needed=strlen($out)+strlen($block)+strlen($footer); if($needed>self::MAX_PINNED_CONTEXT){ throw new \RuntimeException( 'Code Explorer : budget de contexte insuffisant pour injecter EN ENTIER les fichiers obligatoires. '. 'Bloc refuse : '.$path.' ('.$size.' octets). Budget '.self::MAX_PINNED_CONTEXT. ' octets, besoin minimal '.$needed.' octets. Aucun fichier obligatoire n\'a ete tronque.' ); } $out.=$block; } // 2) Contexte secondaire : uniquement des blocs atomiques complets. // On ne coupe plus jamais un bloc avec substr(). $secondaryPaths=array_values(array_filter($selected, static fn($path) => !isset($mandatoryPaths[$path]))); if($secondaryPaths){ $section="\n=== CONTEXTE SECONDAIRE — DEPENDANCES RESOLUES ===\n"; if(strlen($out)+strlen($section)+strlen($footer)<=self::MAX_PINNED_CONTEXT) $out.=$section; } foreach($secondaryPaths as $path){ $content=$contents[$path]??''; $size=strlen($content); // v2.6.0 — les dépendances secondaires servent à COMPRENDRE, pas // encore à réécrire le fichier. On envoie donc un extrait pertinent // et borné. Si le modèle doit modifier ce fichier, il doit demander // explicitement type=path : il sera alors relu intégralement au cycle // suivant et promu dans CONTEXTE OBLIGATOIRE. $snippet=$this->snippet($content,$this->terms($request)); if(strlen($snippet)>self::MAX_SECONDARY_SNIPPET){ $snippet=AiTextEncodingService::slice($snippet,0,self::MAX_SECONDARY_SNIPPET)."\n...[extrait secondaire tronqué]..."; } $hash=hash('sha256',$content); $block="\n--- CONTEXT EXCERPT {$path} | {$size} octets | sha256={$hash} | NON_COMPLET ---\n".$snippet."\n--- END CONTEXT EXCERPT {$path} ---\n"; if(strlen($out)+strlen($block)+strlen($footer)>self::MAX_PINNED_CONTEXT){ $omitted[]=$path; continue; } $out.=$block; } if($omitted){ $note="\nDEPENDANCES SECONDAIRES NON INJECTEES FAUTE DE BUDGET : ".implode(', ',array_slice($omitted,0,12)).".\n"; if(strlen($out)+strlen($note)+strlen($footer)<=self::MAX_PINNED_CONTEXT) $out.=$note; } if(strlen($out)+strlen($footer)>self::MAX_PINNED_CONTEXT){ // Cas defensif : le header seul ne doit jamais rendre le payload invalide. throw new \RuntimeException('Code Explorer : en-tete du contexte verrouille trop volumineux pour le budget disponible.'); } // v2.4.7 — garde-fou interne : chaque path explicite promu obligatoire // doit réellement posséder son BEGIN/END COMPLET dans le payload qui // sera remis au Finalizer. Cette vérification est indépendante de // l'ordre d'exploration et donne un diagnostic précis avant l'appel IA. foreach(array_keys($mandatoryPaths) as $path){ $content=$contents[$path]??''; $size=strlen($content); $hash=hash('sha256',$content); $begin="--- BEGIN PINNED {$path} | {$size} octets | sha256={$hash} | COMPLET ---"; $end="--- END PINNED {$path} | COMPLET ---"; if(!str_contains($out,$begin) || !str_contains($out,$end)){ throw new \RuntimeException( 'Code Explorer : chemin explicite obligatoire non expose integralement avant Finalizer : '. $path.' ('.$size.' octets, sha256='.$hash.').' ); } } return $out.$footer; } /** * v2.4.1 — matérialise les dépendances autorisées (route/search/service/ * symbol/...) en chemins exacts réellement résolus. Ces chemins peuvent * ensuite bénéficier de la garantie v2.3.6 (octets + SHA-256 + COMPLET). */ public function materializeResolvedPathHints(string $request, array $hints): array { if (!$hints) return []; $pinned=$this->explorePinnedDependencies($request,$hints); if ($pinned==='') return []; $out=[];$seen=[]; if (preg_match_all('/--- BEGIN PINNED ([^|\r\n]+?) \| \d+ octets \| sha256=[a-f0-9]{64} \| COMPLET ---/i',$pinned,$m)) { foreach ($m[1] as $path) { $path=trim(str_replace('\\','/',(string)$path),'/ '); if ($path===''||isset($seen[$path])) continue; $seen[$path]=true;$out[]=['type'=>'path','value'=>$path]; } } return $out; } /** * v2.3.6 — garantit qu'un path explicite est présent COMPLET dans le * contexte épinglé et que taille + SHA-256 correspondent aux octets relus * sur le disque. Une simple mention du chemin ne suffit jamais. */ public function assertExactPathsFullyPinned(array $hints, string $context): void { $missing=[]; foreach($this->normalizeDependencyHints($hints) as $hint){ if(($hint['type']??'')!=='path') continue; $path=trim(str_replace('\\','/',(string)($hint['value']??'')),'/'); if($path===''||!$this->allowed($path)) continue; $full=$this->projectRoot.$path; if(!is_file($full)) continue; $content=@file_get_contents($full); if(!is_string($content)) throw new \RuntimeException('Code Explorer : lecture impossible du fichier requis : '.$path); $size=strlen($content); $hash=hash('sha256',$content); $begin='--- BEGIN PINNED '.$path.' | '.$size.' octets | sha256='.$hash.' | COMPLET ---'; $end='--- END PINNED '.$path.' | COMPLET ---'; if(!str_contains($context,$begin)||!str_contains($context,$end)){ $missing[]=$path.' ('.$size.' octets, sha256='.$hash.')'; } } if($missing){ throw new \RuntimeException('Code Explorer : contenu réel COMPLET non garanti dans le payload pour : '.implode(', ',array_slice($missing,0,8)).'.'); } } private function normalizeDependencyHints(array $hints): array { $out=[]; foreach ($hints as $hint) { if (is_array($hint)) { $type=strtolower(trim((string)($hint['type']??''))); $value=trim((string)($hint['path']??$hint['value']??$hint['name']??$hint['method']??$hint['route']??$hint['table']??'')); if (in_array($type,['path','symbol','method','route','table','schema','migration','backup','install','rollback','search','service'],true) && $value!=='') $out[]=['type'=>$type,'value'=>$value]; continue; } $text=trim((string)$hint); if($text==='') continue; if (preg_match_all('~(?:app|public/assets)/[A-Za-z0-9_./-]+\.(?:php|js|css|json|md)~i',$text,$m)) { foreach($m[0] as $v) $out[]=['type'=>'path','value'=>$v]; } if (preg_match_all('/\b[A-Z][A-Za-z0-9_]*(?:Model|Service|Controller)\b/',$text,$m)) foreach($m[0] as $v) $out[]=['type'=>'symbol','value'=>$v]; if (preg_match_all('/\b([A-Za-z_][A-Za-z0-9_]*)\s*\(\)/',$text,$m)) foreach($m[1] as $v) $out[]=['type'=>'method','value'=>$v]; if (preg_match('~(?:route\s+GET\s+|route\s+POST\s+|route\s+)(/?[A-Za-z0-9_./:-]+)~i',$text,$m)) $out[]=['type'=>'route','value'=>$m[1]]; if (preg_match_all('/\b[a-z][a-z0-9_]{2,}\b/',$text,$m)) { foreach($m[0] as $v) if(str_contains($v,'_')) $out[]=['type'=>'table','value'=>$v]; } } $seen=[];$clean=[]; foreach($out as $h){$k=$h['type'].'|'.strtolower($h['value']);if(isset($seen[$k]))continue;$seen[$k]=1;$clean[]=$h;} return array_slice($clean,0,24); } /** * Extrait uniquement les termes explicitement demandés pour une recherche * globale ("occurrence de X", "chercher X", "rechercher X"). */ /** * v2.3.2 — transforme les demandes d'exploration en prose en dépendances * structurées. Aucune cible n'est inventée : les chemins sont vérifiés et les * tables/termes doivent être explicitement présents dans le texte. */ private function normalizeNaturalExplorationPlan(string $summary): array { $root=rtrim($this->projectRoot,DIRECTORY_SEPARATOR); $hints=[];$seen=[]; $add=static function(string $type,string $value) use (&$hints,&$seen): void { $value=trim($value," \t\n\r\0\x0B`\""); if($value==='') return; $key=mb_strtolower($type.'|'.$value); if(isset($seen[$key])) return; $seen[$key]=true; $hints[]=['type'=>$type,'value'=>$value]; }; // Chemins explicites, même lorsqu'ils sont seuls dans un bloc de code. if(preg_match_all('~(?:app|public/assets)/[A-Za-z0-9_./-]+(?:\\.php|\\.js|\\.css|\\.json|\\.md)?~i',$summary,$m)){ foreach($m[0] as $path){ $path=trim(str_replace('\\\\','/',$path),'/'); if($this->readRelative($root,$path)!==null) $add('path',$path); } } // « schéma réel de media », « structure réelle de la table media ». if(preg_match_all('/(?:sch[ée]ma|structure)(?:\\s+(?:sql|r[ée]el(?:le)?|exacte?)){0,3}\\s+(?:de|du|pour)\\s+(?:la\\s+table\\s+)?[`\"\']?([a-z][a-z0-9_]{2,})/iu',$summary,$m)) foreach($m[1] as $table) $add('schema',strtolower($table)); if(preg_match_all('/(?:table)\\s+[`\"\']?([a-z][a-z0-9_]{2,})[`\"\']?.{0,45}(?:sch[ée]ma|structure|colonnes?|index|contraintes?)/iu',$summary,$m)) foreach($m[1] as $table) $add('schema',strtolower($table)); // « migrations associées » hérite de la/les tables explicitement citées // dans la même demande. Cela couvre le cas « schéma réel de media ; les // migrations associées » sans demander au modèle de répéter media. $schemaTables=[]; foreach($hints as $hint) if($hint['type']==='schema') $schemaTables[]=$hint['value']; if($schemaTables && preg_match('/\\bmigrations?\\s+(?:associ[ée]es?|li[ée]es?|correspondantes?|concern[ée]es?)\\b/iu',$summary)) foreach($schemaTables as $table) $add('migration',$table); if(preg_match_all('/\\bmigrations?\\s+(?:de|du|pour|li[ée]es?\\s+[àa])\\s+[`\"\']?([a-z][a-z0-9_]{2,})/iu',$summary,$m)) foreach($m[1] as $table) $add('migration',strtolower($table)); // « recherches globales sur A, B, C et D ». Le segment est borné à la // phrase/liste concernée puis découpé sur virgules, puces et « et ». if(preg_match_all('/(?:recherches?\\s+globales?|rechercher\\s+globalement|chercher\\s+globalement)\\s+(?:sur|de|pour)?\\s*([^\\n.;]{2,500})/iu',$summary,$m)){ foreach($m[1] as $chunk){ $chunk=preg_replace('/\\s+(?:ainsi\\s+que|et)\\s+/iu',',',$chunk)??$chunk; foreach(preg_split('/\\s*[,•]\\s*/u',$chunk)?:[] as $term){ $term=trim($term," \t\n\r\0\x0B`\""); $term=preg_replace('/^(?:les?|la|des?|toutes?\\s+les?)\\s+/iu','',$term)??$term; if($term!=='' && mb_strlen($term)>=2 && mb_strlen($term)<=80) $add('search',$term); } } } // Termes techniques explicitement cités après une intention de recherche. if(preg_match('/(?:recherches?\\s+globales?|occurrences?|chercher|rechercher)/iu',$summary)){ foreach(['MediaModel','stored_name',"WRITEPATH.'uploads'",'WRITEPATH."uploads"','unlink','delete()','delete','uploads/'] as $candidate) if(stripos($summary,$candidate)!==false) $add('search',$candidate); } return array_slice($hints,0,16); } private function extractRequestedSearchTerms(string $summary): array { $terms=[]; $patterns=[ '/(?:occurrences?|recherche(?:r)?|chercher|trouver)\s+(?:de|du|des|pour)?\s*([`\'\"]?)([^\n,;•]{2,80})\1/iu', ]; foreach($patterns as $pattern){ if(!preg_match_all($pattern,$summary,$matches,PREG_SET_ORDER)) continue; foreach($matches as $match){ $chunk=trim((string)($match[2]??'')); $chunk=preg_replace('/^(?:toute|toutes|tout|les|la|le|une|un)\s+/iu','',$chunk)??$chunk; // Une liste "A, B et C" est traitée plus bas via les tokens cités. if($chunk!=='' && mb_strlen($chunk)<=80) $terms[]=$chunk; } } // Les identifiants/code cités explicitement sont les cibles les plus sûres. if(preg_match_all('/`([^`]{2,80})`/u',$summary,$m)) foreach($m[1] as $v) $terms[]=trim($v); // Cas fréquent sans backticks dans une liste d'occurrences. if(preg_match('/occurrences?/iu',$summary)){ foreach(['MediaModel','delete','unlink',"WRITEPATH.'uploads'",'WRITEPATH."uploads"','path','stored_name'] as $candidate){ if(stripos($summary,$candidate)!==false) $terms[]=$candidate; } } $clean=[];$seen=[]; foreach($terms as $term){ $term=trim($term," \t\n\r\0\x0B`'\""); if($term==='' || mb_strlen($term)<2 || mb_strlen($term)>80) continue; $key=mb_strtolower($term); if(isset($seen[$key])) continue; $seen[$key]=true; $clean[]=$term; } return array_slice($clean,0,10); } /** Recherche littérale bornée aux racines autorisées, sans shell ni regex utilisateur. */ private function resolveSearchPaths(string $root,string $needle): array { $needle=trim($needle); if($needle==='' || mb_strlen($needle)>80) return []; $matches=[]; foreach($this->roots as $relativeRoot){ $base=$root.DIRECTORY_SEPARATOR.str_replace('/',DIRECTORY_SEPARATOR,$relativeRoot); if(!is_dir($base)) continue; $it=new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($base,\FilesystemIterator::SKIP_DOTS)); foreach($it as $file){ if(!$file->isFile() || $file->getSize()>self::MAX_FILE_SIZE) continue; $ext=strtolower($file->getExtension()); if(!in_array($ext,$this->extensions,true)) continue; $path=$this->relative($file->getPathname()); if(!$this->allowed($path)) continue; $content=@file_get_contents($file->getPathname()); if(!is_string($content)) continue; $count=substr_count(mb_strtolower($content),mb_strtolower($needle)); if($count<1) continue; $score=min($count,20)*3; if(str_starts_with($path,'app/Controllers/')) $score+=12; elseif(str_starts_with($path,'app/Models/')) $score+=11; elseif(str_starts_with($path,'app/Services/')) $score+=10; elseif(str_starts_with($path,'app/Database/Migrations/')) $score+=8; if(str_contains(mb_strtolower(basename($path)),mb_strtolower(preg_replace('/[^A-Za-z0-9_]/','',$needle)??$needle))) $score+=8; $matches[]=['path'=>$path,'score'=>$score]; } } usort($matches,static fn(array $a,array $b): int => ($b['score']<=>$a['score']) ?: strcmp($a['path'],$b['path'])); return array_column(array_slice($matches,0,6),'path'); } /** Résout les services de gestion de fichiers sans inventer de classe. */ private function resolveServicePaths(string $root,string $value): array { $serviceRoot=$root.DIRECTORY_SEPARATOR.'app/Services'; if(!is_dir($serviceRoot)) return []; $tokens=$value==='file-management' ? ['file','media','upload','storage','image','document','delete','unlink','path','stored_name'] : array_values(array_filter(preg_split('/[^A-Za-z0-9_]+/',mb_strtolower($value))?:[])); $ranked=[]; foreach(new \DirectoryIterator($serviceRoot) as $file){ if(!$file->isFile() || strtolower($file->getExtension())!=='php' || $file->getSize()>self::MAX_FILE_SIZE) continue; $path='app/Services/'.$file->getFilename(); $content=$this->readRelative($root,$path); if($content===null) continue; $hay=mb_strtolower($file->getFilename().' '.$content); $score=0; foreach($tokens as $token) if($token!=='' && str_contains($hay,mb_strtolower($token))) $score++; if($score>0) $ranked[]=['path'=>$path,'score'=>$score]; } usort($ranked,static fn(array $a,array $b): int => ($b['score']<=>$a['score']) ?: strcmp($a['path'],$b['path'])); return array_column(array_slice($ranked,0,5),'path'); } private function pinPath(string $root,string $path,array &$selected,array &$contents,array &$chain,string $reason): void { $path=trim(str_replace('\\','/',$path),'/'); $content=$this->readRelative($root,$path); if($content===null)return; $this->addFile($selected,$contents,$path,$content); $chain[]=$reason.' -> '.$path; } /** Lit la propriété protected $table d'un Model réel. */ private function resolveModelTable(string $root,string $symbol): ?string { if(!str_ends_with($symbol,'Model')) return null; foreach($this->resolveSymbolPaths($root,$symbol) as $path){ $content=$this->readRelative($root,$path); if($content===null) continue; if(preg_match('/protected\s+\$table\s*=\s*[\'"]([A-Za-z0-9_]+)[\'"]\s*;/i',$content,$m)){ $table=preg_replace('/[^A-Za-z0-9_]/','',(string)$m[1]); if($table!=='') return $table; } } return null; } private function resolveSymbolPaths(string $root,string $symbol): array { $symbol=preg_replace('/[^A-Za-z0-9_]/','',$symbol); if($symbol==='')return []; $roots=['app/Models','app/Services','app/Controllers'];$matches=[]; foreach($roots as $base){$dir=$root.DIRECTORY_SEPARATOR.str_replace('/',DIRECTORY_SEPARATOR,$base);if(!is_dir($dir))continue;$it=new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir,\FilesystemIterator::SKIP_DOTS));foreach($it as $f){if($f->isFile()&&strcasecmp($f->getFilename(),$symbol.'.php')===0)$matches[]=$this->relative($f->getPathname());}} return array_values(array_unique($matches)); } private function resolveMethodPaths(string $root,string $method): array { $method=preg_replace('/[^A-Za-z0-9_]/','',$method); if($method==='')return [];$matches=[]; $dir=$root.DIRECTORY_SEPARATOR.'app/Controllers'; if(!is_dir($dir))return []; $it=new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir,\FilesystemIterator::SKIP_DOTS)); foreach($it as $f){if(!$f->isFile()||strtolower($f->getExtension())!=='php')continue;$c=@file_get_contents($f->getPathname());if(is_string($c)&&preg_match('/function\s+'.preg_quote($method,'/').'\s*\(/i',$c))$matches[]=$this->relative($f->getPathname());} return array_slice(array_values(array_unique($matches)),0,6); } private function resolveLifecyclePaths(string $root,string $type,string $value=''): array { $needles=match($type){ 'backup'=>['backup','backups','DevelopmentDatabaseBackupService'], 'rollback'=>['rollback','restore','restoreFiles','DevelopmentDatabaseBackupService'], default=>['install','migrations','DevelopmentProposalService'], }; $matches=[]; // Le service de proposition est la source de vérité du cycle // prévalidation -> backup -> installation -> migration -> rollback. foreach(['app/Services/DevelopmentProposalService.php','app/Services/DevelopmentDatabaseBackupService.php'] as $known){ $c=$this->readRelative($root,$known); if($c!==null){foreach($needles as $n){if(stripos($c,$n)!==false){$matches[]=$known;break;}}} } foreach(['app/Services','app/Controllers/Admin'] as $base){ $dir=$root.DIRECTORY_SEPARATOR.str_replace('/',DIRECTORY_SEPARATOR,$base); if(!is_dir($dir)) continue; $it=new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir,\FilesystemIterator::SKIP_DOTS)); foreach($it as $f){ if(!$f->isFile()||strtolower($f->getExtension())!=='php') continue; $c=@file_get_contents($f->getPathname()); if(!is_string($c)) continue; foreach($needles as $n){ if(stripos($c,$n)!==false){$matches[]=$this->relative($f->getPathname());break;} } if(count($matches)>=10) break 2; } } return array_slice(array_values(array_unique($matches)),0,10); } private function resolveTablePaths(string $root,string $table): array { $table=preg_replace('/[^A-Za-z0-9_]/','',$table);if($table==='')return [];$matches=[]; foreach(['app/Database/Migrations','app/Models'] as $base){$dir=$root.DIRECTORY_SEPARATOR.str_replace('/',DIRECTORY_SEPARATOR,$base);if(!is_dir($dir))continue;$it=new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir,\FilesystemIterator::SKIP_DOTS));foreach($it as $f){if(!$f->isFile()||strtolower($f->getExtension())!=='php')continue;$c=@file_get_contents($f->getPathname());if(is_string($c)&&stripos($c,$table)!==false)$matches[]=$this->relative($f->getPathname());}} return array_values(array_unique($matches)); } /** * Retrouve la route correspondant exactement à la page source connue du * Development Engine. Cela permet de continuer l'exploration même lorsque * la formulation utilisateur ne contient aucun nom de module ou d'URL. */ private function findRoutesByUri(string $content, string $sourceUri): array { $sourceUri = trim($sourceUri, '/'); if ($sourceUri === '') { return []; } $matches = []; foreach ($this->routeDefinitions($content) as $route) { if (trim((string) $route['uri'], '/') !== $sourceUri) { continue; } $route['score'] = 1000; $matches[] = $route; } return $matches; } /** * Cherche les routes dont l'URI complet ou la cible correspond aux termes utilisateur. * Les groupes CodeIgniter sont reconstruits (préfixe + namespace) afin que * "mises-a-jour" dans group('admin', ...) devienne réellement * "/admin/mises-a-jour" et App\\Controllers\\Admin\\Updates. */ private function findMatchingRoutes(string $content, array $terms): array { $matches = []; $asciiRequestTerms = array_values(array_unique(array_filter(array_map(fn($t) => $this->ascii($t), $terms)))); foreach ($this->routeDefinitions($content) as $route) { $haystack = mb_strtolower((string) $route['uri'].' '.(string) $route['controller'].'::'.(string) $route['method']); $asciiHaystack = $this->ascii($haystack); $score = 0; foreach ($terms as $term) { if ($term !== '' && str_contains($haystack, $term)) { $score += 5; } } foreach ($asciiRequestTerms as $term) { if ($term !== '' && str_contains($asciiHaystack, $term)) { $score += 4; } } if ($score === 0) { continue; } $route['score'] = $score; $matches[] = $route; } usort($matches, static fn(array $a, array $b): int => $b['score'] <=> $a['score']); return array_slice($matches, 0, 4); } /** * Extrait les routes directes et celles placées dans un group() simple. * Cette lecture est volontairement déterministe et limitée au format utilisé * par evolution afin d'éviter toute invention de namespace ou de chemin. */ private function routeDefinitions(string $content): array { $routes = []; $covered = []; if (preg_match_all('/\\$routes->group\\(\\s*[\'\"]([^\'\"]+)[\'\"]\\s*,\\s*(\\[[^;]*?\\])?\\s*,\\s*static\\s+function\\s*\\(\\$routes\\)\\s*\\{/is', $content, $groups, PREG_OFFSET_CAPTURE)) { foreach ($groups[0] as $i => $whole) { $groupStart = $whole[1]; $openBrace = $groupStart + strrpos($whole[0], '{'); $closeBrace = $this->matchingBrace($content, $openBrace); if ($closeBrace === null) { continue; } $prefix = trim((string) $groups[1][$i][0], '/'); $options = (string) ($groups[2][$i][0] ?? ''); $namespace = ''; if (preg_match('/[\'\"]namespace[\'\"]\\s*=>\\s*[\'\"]([^\'\"]+)[\'\"]/i', $options, $m)) { $namespace = trim(str_replace('\\\\', '\\', $m[1]), '\\'); } $body = substr($content, $openBrace + 1, $closeBrace - $openBrace - 1); foreach ($this->parseRouteCalls($body, $prefix, $namespace) as $route) { $routes[] = $route; } $covered[] = [$groupStart, $closeBrace + 1]; } } // Routes hors groupes. Les appels déjà compris dans un groupe sont ignorés. if (preg_match_all('/\\$routes->(get|post|put|patch|delete|options|match)\\(\\s*[\'\"]([^\'\"]+)[\'\"]\\s*,\\s*[\'\"]([^\'\"]+)[\'\"]/i', $content, $rows, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) { foreach ($rows as $row) { $offset = $row[0][1]; $inside = false; foreach ($covered as [$a, $b]) { if ($offset >= $a && $offset < $b) { $inside = true; break; } } if ($inside) continue; $parsed = $this->normalizeRoute($row[1][0], $row[2][0], $row[3][0], '', ''); if ($parsed !== null) $routes[] = $parsed; } } return $routes; } private function parseRouteCalls(string $body, string $prefix, string $namespace): array { $routes = []; if (!preg_match_all('/\\$routes->(get|post|put|patch|delete|options|match)\\(\\s*[\'\"]([^\'\"]+)[\'\"]\\s*,\\s*[\'\"]([^\'\"]+)[\'\"]/i', $body, $rows, PREG_SET_ORDER)) { return []; } foreach ($rows as $row) { $route = $this->normalizeRoute($row[1], $row[2], $row[3], $prefix, $namespace); if ($route !== null) $routes[] = $route; } return $routes; } private function normalizeRoute(string $verb, string $uri, string $target, string $prefix, string $namespace): ?array { $target = preg_replace('~/\\$\\d+.*$~', '', $target) ?? $target; if (!str_contains($target, '::')) return null; [$controller, $method] = explode('::', $target, 2); $controller = trim($controller, '\\'); if ($namespace !== '' && !str_contains($controller, '\\')) { $controller = $namespace.'\\'.$controller; } $fullUri = trim(trim($prefix, '/').'/'.trim($uri, '/'), '/'); if ($prefix === '') $fullUri = trim($uri, '/'); return [ 'verb' => strtolower($verb), 'uri' => $fullUri, 'controller' => $controller, 'method' => preg_replace('/[^A-Za-z0-9_].*$/', '', $method) ?: $method, 'score' => 0, ]; } private function matchingBrace(string $content, int $openBrace): ?int { $depth = 0; $inSingle = false; $inDouble = false; $escape = false; $len = strlen($content); for ($i = $openBrace; $i < $len; $i++) { $c = $content[$i]; if ($escape) { $escape = false; continue; } if (($inSingle || $inDouble) && $c === '\\') { $escape = true; continue; } if (!$inDouble && $c === "'") { $inSingle = !$inSingle; continue; } if (!$inSingle && $c === '"') { $inDouble = !$inDouble; continue; } if ($inSingle || $inDouble) continue; if ($c === '{') $depth++; elseif ($c === '}') { $depth--; if ($depth === 0) return $i; } } return null; } /** * Résout le chemin réel d'un contrôleur. * * Dans CodeIgniter, une route située dans un group() avec namespace * peut contenir simplement "Development::index" alors que le fichier * réel est app/Controllers/Admin/Development.php. On ne doit donc pas * déduire aveuglément app/Controllers/Development.php. */ private function resolveControllerPath(string $root, string $controller): string { $normalized = str_replace('\\', '/', trim($controller, '\\')); if (str_starts_with($normalized, 'App/Controllers/')) { $normalized = substr($normalized, strlen('App/Controllers/')); } $candidates = [ 'app/Controllers/' . $normalized . '.php', 'app/Controllers/Admin/' . $normalized . '.php', ]; foreach (array_values(array_unique($candidates)) as $candidate) { if ($this->readRelative($root, $candidate) !== null) { return $candidate; } } // Dernier recours sûr : retrouver un fichier de même nom sous // app/Controllers. On n'accepte le résultat que s'il est unique. $basename = basename($normalized) . '.php'; $controllersRoot = $root . DIRECTORY_SEPARATOR . 'app/Controllers'; $matches = []; if (is_dir($controllersRoot)) { $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($controllersRoot, \FilesystemIterator::SKIP_DOTS)); foreach ($it as $file) { if (!$file->isFile() || $file->getFilename() !== $basename) { continue; } $relative = $this->relative($file->getPathname()); if ($this->allowed($relative)) { $matches[] = $relative; } } } return count($matches) === 1 ? $matches[0] : 'app/Controllers/' . $normalized . '.php'; } private function extractMethodBlock(string $content, string $method): string { $pattern = '/(?:public|protected|private)\\s+function\\s+' . preg_quote($method, '/') . '\\s*\\([^)]*\\)\\s*(?::\\s*[^\\{]+)?\\{/i'; if (!preg_match($pattern, $content, $match, PREG_OFFSET_CAPTURE)) { return ''; } $start = $match[0][1]; $brace = strpos($content, '{', $start); if ($brace === false) { return ''; } $depth = 0; $length = strlen($content); for ($i = $brace; $i < $length; $i++) { if ($content[$i] === '{') { $depth++; } elseif ($content[$i] === '}') { $depth--; if ($depth === 0) { return substr($content, $start, $i - $start + 1); } } } return AiTextEncodingService::slice($content, $start, min(self::MAX_SNIPPET * 2, $length - $start)); } private function extractViews(string $content): array { $views = []; if (preg_match_all("/\\bview\\(\\s*['\"]([^'\"]+)['\"]/", $content, $matches)) { $views = array_merge($views, $matches[1]); } if (preg_match_all("/\\breturn\\s+view\\(\\s*['\"]([^'\"]+)['\"]/", $content, $matches)) { $views = array_merge($views, $matches[1]); } return array_values(array_unique(array_filter(array_map('trim', $views)))); } private function extractAssetPaths(string $viewContent): array { $paths = []; if (preg_match_all("/(?:src|href)\\s*=\\s*['\"](?: if (preg_match_all("~['\"]/?assets/(?:js|css)/[^'\"?]+\\.(?:js|css)~i", $viewContent, $matches)) { foreach ($matches[0] as $raw) { $raw = trim($raw, "'\""); $paths[] = 'public/' . ltrim($raw, '/'); } } return array_values(array_unique($paths)); } private function findCssForView(string $root, string $viewContent): array { $classes = []; if (preg_match_all('/class\\s*=\\s*["\']([^"\']+)["\']/i', $viewContent, $matches)) { foreach ($matches[1] as $classList) { foreach (preg_split('/\\s+/', trim($classList)) ?: [] as $class) { // On ne recherche que les classes suffisamment spécifiques, // pas toutes les classes Bootstrap génériques. if (strlen($class) >= 6 && (str_starts_with($class, 'dev-') || str_starts_with($class, 'evolution-') || str_contains($class, 'prompt'))) { $classes[$class] = true; } } } } if (!$classes) { return []; } $result = []; $cssDir = $root . DIRECTORY_SEPARATOR . 'public/assets'; if (!is_dir($cssDir)) { return []; } $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($cssDir, \FilesystemIterator::SKIP_DOTS)); foreach ($it as $file) { if (!$file->isFile() || strtolower($file->getExtension()) !== 'css' || $file->getSize() > self::MAX_FILE_SIZE) { continue; } $content = @file_get_contents($file->getPathname()); if (!is_string($content)) { continue; } foreach (array_keys($classes) as $class) { if (str_contains($content, '.' . $class)) { $result[$this->relative($file->getPathname())] = $content; break; } } } return $result; } /** * Recherche déterministe des libellés significatifs présents dans la demande. * Les mots génériques sont ignorés : le but est de retrouver le texte réellement * visible dans une vue, pas de deviner un nom de fichier. */ private function literalMatches(string $root, string $request): array { $needles = []; if (preg_match_all('/["“”«»\']([^"“”«»\']{3,80})["“”«»\']/u', $request, $m)) { foreach ($m[1] as $value) $needles[] = trim($value); } // Ajouter les groupes de mots avec majuscule, très fréquents pour les titres // et libellés d'interface : Administration, Mises à jour, Évolution IA, etc. if (preg_match_all('/\b[\p{Lu}][\p{L}\p{N}À-ÿ_-]*(?:\s+(?:à|de|des|du|la|le|les|IA|[\p{Lu}][\p{L}\p{N}À-ÿ_-]*)){0,3}\b/u', $request, $m)) { foreach ($m[0] as $value) $needles[] = trim($value); } $stop = ['Je','Le','La','Les','Un','Une','Il','Elle','Pour','Dans','Sur','Page','Titre','Icon','Icône','Proposition']; $needles = array_values(array_unique(array_filter($needles, static fn(string $v): bool => mb_strlen($v) >= 4 && !in_array($v, $stop, true)))); usort($needles, static fn(string $a, string $b): int => mb_strlen($b) <=> mb_strlen($a)); $needles = array_slice($needles, 0, 8); if (!$needles) return []; $scored = []; foreach ($this->roots as $allowedRoot) { $dir = $root . DIRECTORY_SEPARATOR . $allowedRoot; if (!is_dir($dir)) continue; $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)); foreach ($it as $file) { if (!$file->isFile() || $file->getSize() > self::MAX_FILE_SIZE) continue; if (!in_array(strtolower($file->getExtension()), $this->extensions, true)) continue; $path = $this->relative($file->getPathname()); if (!$this->allowed($path)) continue; $content = @file_get_contents($file->getPathname()); if (!is_string($content)) continue; $score = 0; foreach ($needles as $needle) { $count = substr_count(mb_strtolower($content), mb_strtolower($needle)); if ($count) $score += 20 + min($count, 5) * 3 + min(mb_strlen($needle), 30); } if ($score) $scored[$path] = ['score'=>$score, 'content'=>$content]; } } uasort($scored, static fn(array $a, array $b): int => $b['score'] <=> $a['score']); $out=[]; foreach (array_slice($scored, 0, 4, true) as $path=>$row) $out[$path]=$row['content']; return $out; } /** Suit sans écriture les références explicites découvertes dans une vue. */ private function followFileReferences(string $root, string $path, string $content, array &$selected, array &$contents, array &$forcedSnippets, array &$chain): void { if (!str_starts_with($path, 'app/Views/')) return; $views = $this->extractViews($content); if (preg_match_all('/\$this->(?:extend|include)\(\s*[\'\"]([^\'\"]+)[\'\"]\s*\)/', $content, $m)) { $views = array_merge($views, $m[1]); } foreach (array_values(array_unique($views)) as $view) { if (str_contains($view, '$') || str_contains($view, '{')) continue; $viewPath='app/Views/'.trim($view,'/').'.php'; $viewContent=$this->readRelative($root,$viewPath); if ($viewContent===null) continue; $this->addFile($selected,$contents,$viewPath,$viewContent); $forcedSnippets[$viewPath]=$viewContent; $chain[]='Référence de vue suivie automatiquement -> '.$viewPath; } foreach ($this->extractAssetPaths($content) as $assetPath) { $assetContent=$this->readRelative($root,$assetPath); if ($assetContent===null) continue; $this->addFile($selected,$contents,$assetPath,$assetContent); $chain[]='Asset suivi automatiquement -> '.$assetPath; } } private function scoreProjectFiles(string $root, array $terms): array { $scores = []; foreach ($this->roots as $allowedRoot) { $dir = $root . DIRECTORY_SEPARATOR . $allowedRoot; if (!is_dir($dir)) { continue; } $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)); foreach ($it as $file) { if (!$file->isFile() || $file->getSize() > self::MAX_FILE_SIZE) { continue; } $ext = strtolower($file->getExtension()); if (!in_array($ext, $this->extensions, true)) { continue; } $path = $this->relative($file->getPathname()); if (!$this->allowed($path)) { continue; } $content = @file_get_contents($file->getPathname()); if (!is_string($content)) { continue; } $haystack = mb_strtolower($path . "\n" . $content); $asciiHaystack = $this->ascii($haystack); $score = 0; foreach ($terms as $term) { $count = substr_count($haystack, $term); if ($count > 0) { $score += min($count, 8); } $asciiTerm = $this->ascii($term); if ($asciiTerm !== '' && $asciiTerm !== $term && str_contains($asciiHaystack, $asciiTerm)) { $score += 2; } if (str_contains(mb_strtolower($path), $term)) { $score += 8; } } if ($score > 0) { $scores[$path] = $score; } } } arsort($scores); return $scores; } /** * Les fichiers déterministes (contrôleur, vue, layout) doivent être transmis * intégralement lorsqu'ils restent de taille raisonnable. Un simple snippet de * 30 Ko pouvait auparavant couper une méthode au milieu et empêcher l'IA de * produire un diff fiable. */ private function targetContent(string $content): string { if (strlen($content) <= self::MAX_TARGET_CONTENT) { return $content; } // Pour les fichiers exceptionnellement volumineux, conserver des blocs // clairement signalés plutôt que de prétendre avoir fourni le fichier entier. $half = intdiv(self::MAX_TARGET_CONTENT, 2); return AiTextEncodingService::slice($content, 0, $half) . "\n\n/* === evolution: partie centrale omise (fichier > limite ciblée) === */\n\n" . AiTextEncodingService::slice($content, -$half); } private function addFile(array &$selected, array &$contents, string $path, string $content): void { if (!$this->allowed($path) || isset($contents[$path])) { return; } $selected[] = $path; $contents[$path] = $content; } private function readRelative(string $root, string $path): ?string { if (!$this->allowed($path)) { return null; } $full = $root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $path); if (!is_file($full) || filesize($full) > self::MAX_FILE_SIZE) { return null; } $realRoot = realpath($root); $realFull = realpath($full); if ($realRoot === false || $realFull === false || !str_starts_with($realFull, $realRoot . DIRECTORY_SEPARATOR)) { return null; } $content = @file_get_contents($realFull); return is_string($content) ? $content : null; } private function terms(string $request): array { $text = mb_strtolower(strip_tags($request)); $words = preg_split('/[^\\p{L}\\p{N}_-]+/u', $text, -1, PREG_SPLIT_NO_EMPTY) ?: []; $stop = [ 'avec','dans','pour','mais','plus','cette','cela','comme','faire','mettre','mise','jour','actuel','actuelle', 'être','avoir','tout','tous','toute','toutes','une','des','les','que','qui','sur','mon','ma','mes','du','de','la', 'le','un','et','ou','est','sont','je','tu','il','elle','nous','vous','ils','elles','trop','petit','petite','page', 'formulaire','analyse','analyser','propose','proposer','amélioration','amelioration','retrouve','retrouver','même','meme' ]; $words = array_values(array_filter( $words, static fn(string $word): bool => mb_strlen($word) >= 3 && !in_array($word, $stop, true) )); // Ajouter les variantes ASCII facilite evolution <-> évolution. $terms = []; foreach ($words as $word) { $terms[] = $word; $ascii = $this->ascii($word); if ($ascii !== '' && $ascii !== $word) { $terms[] = $ascii; } } return array_slice(array_values(array_unique($terms)), 0, 12); } private function snippet(string $content, array $terms): string { if (strlen($content) <= self::MAX_SNIPPET) { return $content; } $lower = mb_strtolower($content); $position = false; foreach ($terms as $term) { $pos = mb_strpos($lower, $term); if ($pos !== false) { $position = $pos; break; } } if ($position === false) { return AiTextEncodingService::slice($content, 0, self::MAX_SNIPPET); } $prefix = mb_substr($content, 0, max(0, $position - 800)); $bytePosition = strlen($prefix); return AiTextEncodingService::slice($content, max(0, $bytePosition), self::MAX_SNIPPET); } private function ascii(string $value): string { $converted = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value); return mb_strtolower(is_string($converted) ? $converted : $value); } private function relative(string $path): string { $root = rtrim(str_replace('\\', '/', $this->projectRoot), '/'); return ltrim(substr(str_replace('\\', '/', $path), strlen($root)), '/'); } private function allowed(string $path): bool { $path = ltrim(str_replace('\\', '/', $path), '/'); if ($path === '' || str_contains($path, '..')) { return false; } foreach (['.env', '.git/', '.ssh/', 'writable/', 'public/uploads/'] as $blocked) { if (str_starts_with($path, $blocked) || str_contains($path, '/' . $blocked)) { return false; } } $inReadableRoot = str_starts_with($path, 'app/') || str_starts_with($path, 'public/') || str_starts_with($path, 'scripts/') || str_starts_with($path, 'install/'); $rootReadable = in_array($path, ['composer.json','composer.lock','spark','.htaccess','env.example','env.ovh.example','README-RESTORE.md'], true); if (!$inReadableRoot && !$rootReadable) return false; if ($rootReadable) return true; $base = strtolower(basename($path)); if ($base === '.htaccess') return true; $ext = strtolower((string) pathinfo($path, PATHINFO_EXTENSION)); return in_array($ext, ['php','js','css','json','md','txt','sql','xml','yml','yaml','ini','lock','example','dist','sh','svg'], true); } } --- END PINNED app/Services/DevelopmentCodeExplorerService.php | COMPLET --- REGLE FINALE : ne demande jamais de relire un fichier marque COMPLET ci-dessus. Produis maintenant files[]/database ou indique uniquement une dependance concrete absente. CONTEXTE TECHNIQUE evolution (généré automatiquement) Version installée : 3.10.5 Framework : CodeIgniter 4 UI : Bootstrap 5 + Bootstrap Icons Base : MySQL/MariaDB API IA : OpenAI Responses API avec streaming Hébergement : environnement web PHP, déploiement compatible OVH mutualisé Mise à jour : gestionnaire ZIP versionné avec migrations CodeIgniter, sauvegarde préalable et historique app_versions. Fichiers/données protégés : .env, .env.ovh, .git, .ssh, writable, public/uploads. Tables actuellement visibles : dev_ai_access, dev_ai_access_logs, dev_ai_access_requests, dev_ai_usage_logs, dev_conversations, dev_logs, dev_messages, dev_project_knowledge, dev_proposal_database, dev_proposal_files, dev_proposal_git_states, dev_proposals, migrations, password_resets, settings, users. Important : l’assistant utilisateur /app est distinct de ce Development Engine d’administration. Ne pas mélanger leurs conversations ni leurs prompts. ======================================================================== [2] INPUT / PROMPT + CONTEXTE ======================================================================== PHASE FINALISATION evolution. INTENTION RACINE VERROUILLEE sha256=a0c379af102d35469837e7c0099172efdb3070177a04de642cdf00d3ee4096d6. Le contexte technique est final et déjà exploré. Réponds UNIQUEMENT avec un objet JSON valide, sans markdown. Schéma autorisé: {"status":"proposal|no_change|needs_context","title":string,"summary":string,"files":[{"path":string,"action":"modify|create","content":string,"edits":[{"symbol":"method:nom","replace":string}|{"search":string,"replace":string}|{"start":string,"end":string,"replace":string}]}],"database":null|{"mode":"migration","summary":string,"migration_path":string,"content":string,"risk_level":"low|medium|high","reversible":bool},"needs_context":[{"type":"path|symbol|method|route|table|schema|migration|backup|install|rollback|search|service","value":string}]}. Pour action=modify, N ENVOIE PAS le fichier complet : utilise exclusivement edits[] avec les plus petits remplacements exacts possibles et omets content. Chaque search doit être une chaîne exacte présente UNE SEULE FOIS dans le fichier réel épinglé ; replace contient sa version finale. Pour une méthode PHP, préfère TOUJOURS symbol="method:Nom"/replace. Pour un autre bloc volumineux, préfère start/end/replace : start et end sont deux ancres exactes, courtes et uniques du fichier, et replace contient la zone finale complète, ancres comprises. N’envoie jamais un search gigantesque simplement pour remplacer un gros bloc. evolution reconstruit localement le fichier complet, puis effectue ses validations, son diff et son SHA-256 sans faire payer sa réémission au modèle. Pour action=create seulement, utilise content avec le contenu complet et edits=[]. Tu ne peux demander une lecture tardive QUE si un composant indispensable manque réellement du contexte final. Si tu identifies des fichiers ou tables manquants, retourne obligatoirement status=needs_context avec needs_context structuré : n'écris jamais une section prose 'Étape requise' ou 'Exploration requise' destinée à l'administrateur. Dans ce cas, status=needs_context, files=[], database=null et needs_context doit contenir uniquement des cibles techniques concrètes et structurées. Pour un fichier précis du PROJET CIBLE, utilise type=path avec son chemin exact. Les blocs CROSS PROJECT SOURCE sont déjà lus intégralement et sont strictement en lecture seule : ne redemande pas leur path comme s'il appartenait au projet cible et ne les place jamais dans files[]. Pour une structure de table utilise schema, pour ses migrations migration, et pour les règles du cycle d’exécution backup/install/rollback. Pour un Model CodeIgniter, demande son symbole : evolution fournit automatiquement le modèle complet puis sa table déclarée, son schéma réel, ses index/contraintes et ses migrations. Pour une recherche littérale indispensable dans le code, utilise search avec le terme exact. Pour les services liés aux fichiers/uploads/media, utilise service avec file-management. Si tu émets malgré tout dans summary des lignes structurées comme path:, schema:, migration:, symbol:, search: ou service:, evolution les exécutera comme plan interne et te rappellera automatiquement ; elles ne sont jamais destinées à l'administrateur. Tout bloc "BEGIN DATABASE SCHEMA | REAL DATABASE" est une lecture réelle et épinglée de la base connectée : tu ne peux pas déclarer ce schéma absent s'il est présent. N’annonce jamais simplement une prochaine étape. BUDGET SORTIE STRICT : minimise le JSON. summary bref. Pour un fichier existant, ne recopie aucune portion inchangée hors des petites ancres nécessaires. Utilise symbol/replace pour les méthodes PHP, sinon start/end pour éviter les gros search. Si une modification est nécessaire, status DOIT être proposal et files[] et/ou database DOIT être non vide. Pour chaque fichier existant modifié, retourne uniquement des edits exacts minimaux ; ne réémets jamais son contenu complet. Pour un nouveau fichier seulement, fournis content complet. Si réellement aucune modification n’est nécessaire, utilise status=no_change, files=[], database=null et justifie précisément dans summary. Maximum 7 fichiers migration comprise. Chemins autorisés: app/ et public/assets/. Ne touche jamais .env, writable, public/uploads, .git ou vendor. REGLE CANONIQUE MIGRATION BDD : une migration est représentée EXCLUSIVEMENT dans database. Si database != null, database.migration_path contient son chemin et database.content son contenu PHP COMPLET. Le même migration_path NE DOIT JAMAIS apparaître dans files[]. files[] contient uniquement les autres fichiers applicatifs. REGLE CANONIQUE FILES : un même path ne doit apparaître qu’une seule fois dans files[]. Si plusieurs zones d’un fichier doivent changer, regroupe tous leurs edits[] dans cette unique entrée. La migration compte néanmoins dans la limite globale de 7 fichiers. Si une migration est nécessaire, utilise CodeIgniter 4 Forge/Query Builder, namespace App\Database\Migrations, extends Migration, up() et down(), sans SQL brut. Nom conseillé: app/Database/Migrations/2026-10-10-232904_NomDescriptif.php. DIAGNOSTIC evolution: Contexte garanti : 1 fichier(s) réellement injecté(s) : app/Services/DevelopmentCodeExplorerService.php (83944 octets). DEMANDE RACINE IMMUABLE: DEMANDE RACINE (IMMUTABLE) : Modifie le titre "Nexum AI - Développement" en "Matrice de développement" dans la page actuelle IMPORTANT: les tâches d exploration (ZIP, rotation, route, vue, modèle, validation, etc.) sont seulement des vérifications intermédiaires et ne remplacent jamais cette demande racine. CONTROLE PAYLOAD EFFECTIF : le bloc CONTEXT PAYLOAD INSPECTOR placé en tête du contexte décrit exactement les fichiers et schémas présents dans CE payload. Tu dois le considérer comme autoritatif. ======================================================================== PAYLOAD COMPLET (PARAMÈTRES HORS SECRETS) ======================================================================== { "model": "gpt-5.6-terra", "instructions": "Tu es un moteur de développement logiciel. Analyse le projet local, demande les autorisations nécessaires avant de lire ou modifier les fichiers, puis propose des changements précis, réversibles et vérifiables.\n\n---\n\n=== BEGIN CONTEXT PAYLOAD INSPECTOR ===\nPayload final: 86005 octets | sha256=86f1396d5c4896edbda7b3b099efb20dfceeca6ea596042ba30c6311b3070d00\n[OK] app/Services/DevelopmentCodeExplorerService.php | 83944 octets\n=== END CONTEXT PAYLOAD INSPECTOR ===\n\n\n\n=== CONTEXTE FINAL VERROUILLE evolution — SOURCE DE VERITE ===\nv2.4.7 : tout chemin exact explicite et resolu est promu OBLIGATOIRE, meme s'il avait deja ete decouvert. Les blocs PINNED sont injectes entiers et jamais tronques.\nCes fichiers viennent d'etre relus sur le disque juste avant la generation du diff. Les marqueurs COMPLET signifient que le contenu entre BEGIN/END est integral.\n\nPLAN D'EXPLORATION EXECUTE PAR evolution :\n- path: app/Services/DevelopmentCodeExplorerService.php => 1 nouveau(x) fichier(s), cible exacte promue OBLIGATOIRE\n- PIN path obligatoire -> app/Services/DevelopmentCodeExplorerService.php\n\n=== CONTEXTE OBLIGATOIRE — CHEMINS EXACTS AUTORISES ===\n\n--- BEGIN PINNED app/Services/DevelopmentCodeExplorerService.php | 83944 octets | sha256=058ae1dfc15a62a08526b853bd549d24d92eeaa9dfca40ae35e99a0db1809559 | COMPLET ---\n Controller -> méthode -> View -> assets.\n */\nclass DevelopmentCodeExplorerService\n{\n private const MAX_FILES = 10;\n private const MAX_SNIPPET = 30000;\n private const MAX_TARGET_CONTENT = 70000;\n private const MAX_PINNED_FILE = 220000;\n private const MAX_PINNED_CONTEXT = 260000;\n private const MAX_SECONDARY_SNIPPET = 14000;\n private const MAX_CONTEXT = 180000;\n private const MAX_FILE_SIZE = 900000;\n\n private array $roots = ['app', 'public/assets'];\n private array $extensions = ['php', 'js', 'css', 'json', 'md'];\n private string $projectRoot;\n\n public function __construct(?string $projectId=null)\n {\n $project=(new DevelopmentProjectManagerService())->resolve($projectId?:'system');\n $this->projectRoot=rtrim((string)$project['root'],'\\\\/').DIRECTORY_SEPARATOR;\n if(($project['type']??'')==='codeigniter3') $this->roots=['application','assets'];\n }\n\n public function explore(string $request, ?string $sourceUri = null): string\n {\n $terms = $this->terms($request);\n $sourceUri = trim((string) $sourceUri, '/');\n if (!$terms && $sourceUri === '') {\n return '';\n }\n\n $root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR);\n $selected = [];\n $contents = [];\n $chain = [];\n $forcedSnippets = [];\n\n // 1. Routes : toujours examinées en premier, car elles permettent de\n // remonter proprement vers le contrôleur même si son nom n'apparaît pas\n // dans la demande utilisateur.\n $routesPath = 'app/Config/Routes.php';\n $routesContent = $this->readRelative($root, $routesPath);\n if ($routesContent !== null) {\n // La page depuis laquelle le Development Engine est appelé est un\n // ancrage fiable, même si la demande est vague (ex. \"trouve tout seul\").\n // Priorité à la cible exprimée dans la demande. L'URI source n'est\n // qu'un filet de sécurité pour les demandes vagues portant sur la page courante.\n $routeMatches = $this->findMatchingRoutes($routesContent, $terms);\n if(!$routeMatches && $sourceUri !== '') {\n $routeMatches = $this->findRoutesByUri($routesContent, $sourceUri);\n }\n\n // Dédupliquer les routes retenues.\n foreach ([] as $candidate) {\n $key = strtolower($candidate['verb'].'|'.$candidate['uri'].'|'.$candidate['controller'].'|'.$candidate['method']);\n $exists = false;\n foreach ($routeMatches as $existing) {\n $existingKey = strtolower($existing['verb'].'|'.$existing['uri'].'|'.$existing['controller'].'|'.$existing['method']);\n if ($existingKey === $key) { $exists = true; break; }\n }\n if (!$exists) { $routeMatches[] = $candidate; }\n }\n\n if ($routeMatches) {\n $this->addFile($selected, $contents, $routesPath, $routesContent);\n\n foreach ($routeMatches as $route) {\n $chain[] = sprintf(\n 'Route %s %s -> %s::%s()',\n strtoupper($route['verb']),\n $route['uri'],\n $route['controller'],\n $route['method']\n );\n\n // 2. Contrôleur réellement pointé par la route.\n $controllerPath = $this->resolveControllerPath($root, $route['controller']);\n $controllerContent = $this->readRelative($root, $controllerPath);\n if ($controllerContent === null) {\n continue;\n }\n\n $this->addFile($selected, $contents, $controllerPath, $controllerContent);\n\n // 3. Méthode ciblée : extraire en priorité son bloc pour\n // identifier les vues sans dépendre du score de recherche.\n $methodBlock = $this->extractMethodBlock($controllerContent, $route['method']);\n // Pour les propositions, fournir le contrôleur réel (pas seulement\n // le dernier bloc de méthode rencontré). Plusieurs routes peuvent pointer\n // vers le même contrôleur et écrasaient auparavant index() avec finalize().\n $forcedSnippets[$controllerPath] = $controllerContent;\n $views = $this->extractViews($methodBlock ?: $controllerContent);\n\n foreach ($views as $view) {\n // Les vues dynamiques ne sont pas résolues ici : on ne\n // doit jamais inventer leur chemin.\n if (str_contains($view, '$') || str_contains($view, '{')) {\n continue;\n }\n\n $viewPath = 'app/Views/' . trim($view, '/') . '.php';\n $viewContent = $this->readRelative($root, $viewPath);\n if ($viewContent === null) {\n continue;\n }\n\n $chain[] = sprintf('%s::%s() -> vue %s', $route['controller'], $route['method'], $viewPath);\n $this->addFile($selected, $contents, $viewPath, $viewContent);\n $forcedSnippets[$viewPath] = $viewContent;\n\n // 4. Assets explicitement référencés par la vue.\n foreach ($this->extractAssetPaths($viewContent) as $assetPath) {\n $assetContent = $this->readRelative($root, $assetPath);\n if ($assetContent !== null) {\n $chain[] = sprintf('Vue %s -> asset %s', $viewPath, $assetPath);\n $this->addFile($selected, $contents, $assetPath, $assetContent);\n }\n }\n\n // 5. Classes CSS spécifiques de la vue : chercher leurs\n // définitions dans les feuilles locales connues.\n foreach ($this->findCssForView($root, $viewContent) as $cssPath => $cssContent) {\n $chain[] = sprintf('Styles spécifiques détectés -> %s', $cssPath);\n $this->addFile($selected, $contents, $cssPath, $cssContent);\n }\n }\n }\n }\n }\n\n // 6. Exploration autonome multi-étapes : une demande d'interface contient\n // souvent un libellé visible (ex. \"Administration\") sans URL ni nom de\n // contrôleur. On recherche d'abord ces expressions littérales dans le code\n // réel, puis on suit les références de vues/layouts/assets trouvées.\n foreach ($this->literalMatches($root, $request) as $path => $content) {\n if (count($selected) >= self::MAX_FILES) {\n break;\n }\n $this->addFile($selected, $contents, $path, $content);\n $forcedSnippets[$path] = $content;\n $chain[] = 'Texte/interface localisé automatiquement -> ' . $path;\n $this->followFileReferences($root, $path, $content, $selected, $contents, $forcedSnippets, $chain);\n }\n\n // 7. Recherche textuelle générale en complément de la chaîne résolue.\n // Elle sert pour les services, modèles, JS ou CSS non explicitement liés.\n $scores = $this->scoreProjectFiles($root, $terms);\n foreach (array_keys($scores) as $path) {\n if (count($selected) >= self::MAX_FILES) {\n break;\n }\n if (isset($contents[$path])) {\n continue;\n }\n $content = $this->readRelative($root, $path);\n if ($content !== null) {\n $this->addFile($selected, $contents, $path, $content);\n }\n }\n\n if (!$selected) {\n return '';\n }\n\n $out = \"\\n\\n=== CODE EXPLORER evolution — LECTURE SEULE ===\\n\";\n $out .= \"Les informations suivantes proviennent du code réellement présent sur le serveur. \"\n . \"Utilise-les comme source de vérité. Ne prétends jamais avoir lu un fichier absent de cette section.\\n\";\n\n if ($chain) {\n $out .= \"\\nCHAÎNE TECHNIQUE RÉSOLUE AUTOMATIQUEMENT :\\n\";\n foreach (array_values(array_unique($chain)) as $step) {\n $out .= \"- {$step}\\n\";\n }\n }\n\n $out .= \"\\nFICHIERS LUS AUTOMATIQUEMENT :\\n\";\n foreach (array_slice($selected, 0, self::MAX_FILES) as $path) {\n $content = $contents[$path] ?? '';\n $out .= \"\\n--- {$path} ---\\n\";\n if (isset($forcedSnippets[$path])) {\n $out .= $this->targetContent($forcedSnippets[$path]) . \"\\n\";\n } else {\n $out .= $this->snippet($content, $terms) . \"\\n\";\n }\n if (strlen($out) >= self::MAX_CONTEXT) {\n break;\n }\n }\n\n $out .= \"\\nRÈGLE D'UTILISATION : si la chaîne Route -> Controller -> méthode -> View est résolue ci-dessus, \"\n . \"ne demande pas à l'administrateur de fournir manuellement ces fichiers. Analyse directement les contenus fournis. Les fichiers marqués comme cibles déterministes sont transmis intégralement jusqu'à 70 Ko ; ne les considère pas comme tronqués s'ils ne portent pas le marqueur d'omission evolution.\\n\";\n\n return AiTextEncodingService::slice($out, 0, self::MAX_CONTEXT);\n }\n\n /**\n * Pré-résolution sémantique des demandes d'interface.\n *\n * Elle ne donne aucun droit d'écriture : elle transforme uniquement une\n * intention UI (apparence, boutons, thème, réglages, layout...) en cibles\n * techniques vérifiées sur le disque. Le but est d'éviter d'attendre que\n * le modèle redemande des fichiers évidents au premier cycle.\n */\n public function inferSemanticDependencies(string $request): array\n {\n $root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR);\n $lower = $this->ascii(mb_strtolower($request));\n $isUi = preg_match('/\\b(apparence|bouton|buttons?|theme|couleur|style|css|interface|layout|sidebar|menu|formulaire|bordure|radius|arrondi|admin)\\b/', $lower) === 1;\n $isSettings = preg_match('/\\b(reglage|reglages|settings?|parametre|parametres|apparence|theme)\\b/', $lower) === 1;\n $isDevelopmentUi = preg_match('/\\b(evolution ia|evolution-ia|development engine|development|moteur de developpement|conversation technique|code explorer|controleur de streaming|controller de streaming|streaming)\\b/', $lower) === 1;\n if (!$isUi && !$isSettings && !$isDevelopmentUi) return [];\n\n $hints = [];\n $seen = [];\n $add = static function(array $hint) use (&$hints, &$seen): void {\n $key = strtolower(($hint['type'] ?? '').'|'.($hint['value'] ?? ''));\n if ($key === '|' || isset($seen[$key])) return;\n $seen[$key] = true;\n $hints[] = $hint;\n };\n $addPath = function(string $path) use ($root, $add): void {\n if ($this->readRelative($root, $path) !== null) $add(['type'=>'path','value'=>$path]);\n };\n\n // Une demande explicitement limitée à l'administration doit toujours\n // connaître le layout admin et ses assets locaux avant finalisation.\n if (str_contains($lower, 'admin')) {\n $addPath('app/Views/layouts/admin.php');\n $layout = $this->readRelative($root, 'app/Views/layouts/admin.php');\n if ($layout !== null) {\n foreach ($this->extractAssetPaths($layout) as $assetPath) $addPath($assetPath);\n }\n }\n\n // v2.3.5 — l'interface propre à Évolution IA est une cible de lecture\n // de premier ordre. Lorsqu'une demande concerne le Development Engine,\n // on expose directement sa vue principale, son contrôleur, son layout et\n // ses scripts locaux avant toute finalisation.\n if ($isDevelopmentUi) {\n foreach ([\n 'app/Views/admin/development/index.php',\n 'app/Controllers/Admin/Development.php',\n 'app/Views/layouts/admin.php',\n 'public/assets/js/development-engine.js',\n 'public/assets/js/development-voice.js',\n ] as $path) $addPath($path);\n $routes = $this->readRelative($root, 'app/Config/Routes.php');\n if ($routes !== null && $this->findRoutesByUri($routes, 'admin/evolution-ia')) {\n $add(['type'=>'route','value'=>'admin/evolution-ia']);\n }\n }\n\n // Apparence/réglages : suivre la vraie route si elle existe, puis le\n // système de settings et ses migrations. Toutes les cibles sont ensuite\n // résolues par les mécanismes path/symbol/table existants.\n if ($isSettings) {\n $routes = $this->readRelative($root, 'app/Config/Routes.php');\n if ($routes !== null) {\n foreach (['admin/reglages', 'reglages'] as $uri) {\n if ($this->findRoutesByUri($routes, $uri)) { $add(['type'=>'route','value'=>$uri]); break; }\n }\n }\n foreach (['app/Controllers/Admin/Settings.php','app/Views/admin/settings.php'] as $path) $addPath($path);\n foreach (['SettingService','SettingModel'] as $symbol) {\n if ($this->resolveSymbolPaths($root, $symbol)) $add(['type'=>'symbol','value'=>$symbol]);\n }\n if ($this->resolveTablePaths($root, 'settings')) $add(['type'=>'table','value'=>'settings']);\n }\n\n // Si l'UI admin est ciblée mais le mot réglages n'est pas écrit, les\n // styles locaux du layout restent nécessaires pour décider où appliquer\n // les variables sans toucher à l'espace membre.\n if ($isUi && str_contains($lower, 'admin')) {\n foreach (['public/assets/css/evolution.css','public/assets/css/evolution-theme.css'] as $path) $addPath($path);\n }\n\n return array_slice($hints, 0, 16);\n }\n\n /**\n * Convertit une intention de poursuite exprimée en langage naturel\n * (ex. \"explorer la partie publique du diaporama\") en dépendances\n * techniques réelles. Aucune cible n'est inventée : seules les routes,\n * symboles et fichiers effectivement présents sur disque sont retournés.\n */\n public function inferDeferredDependencies(string $request, string $summary, array $alreadySeen = []): array\n {\n $root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR);\n $text = trim($request.' '.$summary);\n if ($text === '') return [];\n\n $hints = [];\n $seenKeys = [];\n foreach ($alreadySeen as $hint) {\n $seenKeys[is_array($hint) ? json_encode($hint) : 's:'.(string)$hint] = true;\n }\n $add = static function(array $hint) use (&$hints, &$seenKeys): void {\n $key = json_encode($hint);\n if (isset($seenKeys[$key])) return;\n $seenKeys[$key] = true;\n $hints[] = $hint;\n };\n\n // v2.3.1 : si le Finalizer écrit déjà un plan structuré dans sa prose,\n // l'exécuter tel quel. Cela couvre notamment \"symbol: Library\",\n // \"schema: media\", \"migration: media\" et \"search: csrf_hash\".\n if (preg_match_all('/^\\s*(?:[-*]\\s*)?(path|symbol|method|route|table|schema|migration|backup|install|rollback|search|service)\\s*:\\s*(.+?)\\s*$/mi', $summary, $structured, PREG_SET_ORDER)) {\n foreach ($structured as $row) {\n $type = strtolower(trim((string) $row[1]));\n $value = trim((string) $row[2], \" \\t\\n\\r\\0\\x0B`\");\n if ($type === 'path') $value = trim(str_replace('\\\\\\\\', '/', $value), '/');\n if ($value === '') continue;\n if ($type === 'path' && $this->readRelative($root, $value) === null) continue;\n $add(['type'=>$type,'value'=>$value]);\n }\n }\n\n // v2.3.2 — normalisation du langage naturel en plan d'exploration.\n // Le Finalizer n'est pas obligé d'émettre les préfixes path:/schema:/...\n // pour que evolution exécute une demande explicite. Les formulations restent\n // volontairement bornées et déterministes : chemins existants, tables\n // nommées et termes de recherche littéraux réellement cités.\n $natural = $this->normalizeNaturalExplorationPlan($summary);\n foreach ($natural as $hint) $add($hint);\n\n // v2.2.8 : une formulation tardive comme « le Code Explorer doit lire »\n // reste une dépendance exécutable, jamais une tâche pour l'administrateur.\n // 1) Tout chemin explicite réellement présent reste prioritaire.\n if (preg_match_all('~(?:app|public/assets)/[A-Za-z0-9_./-]+(?:\\.php|\\.js|\\.css|\\.json|\\.md)?~i', $summary, $m)) {\n foreach ($m[0] as $path) {\n $path = trim(str_replace('\\\\', '/', $path), '/');\n if ($this->readRelative($root, $path) !== null) $add(['type'=>'path','value'=>$path]);\n }\n }\n\n // 2) Pour une demande \"publique / membre / familiale\", résoudre les\n // routes non-admin correspondant au sujet réel de la demande.\n $lower = mb_strtolower($summary);\n $publicIntent = preg_match('/\\b(public(?:que)?|membre|familial(?:e)?|front(?:end)?|côté membre|cote membre)\\b/u', $lower) === 1;\n $routesContent = $this->readRelative($root, 'app/Config/Routes.php');\n if ($routesContent !== null) {\n $terms = $this->terms($text);\n $routes = $this->routeDefinitions($routesContent);\n $ranked = [];\n foreach ($routes as $route) {\n $uri = trim((string)($route['uri'] ?? ''), '/');\n $controller = (string)($route['controller'] ?? '');\n if ($publicIntent && (str_starts_with($uri, 'admin/') || str_contains($controller, '\\\\Admin\\\\') || $uri === 'admin')) continue;\n $haystack = $this->ascii(mb_strtolower($uri.' '.$controller.'::'.(string)($route['method'] ?? '')));\n $score = 0;\n foreach ($terms as $term) {\n $needle = $this->ascii($term);\n if ($needle !== '' && str_contains($haystack, $needle)) $score += 5;\n }\n if ($score > 0) { $route['score'] = $score; $ranked[] = $route; }\n }\n usort($ranked, static fn(array $a,array $b): int => ($b['score']??0) <=> ($a['score']??0));\n foreach (array_slice($ranked,0,6) as $route) {\n $uri = trim((string)($route['uri'] ?? ''), '/');\n if ($uri !== '') $add(['type'=>'route','value'=>$uri]);\n }\n }\n\n // 3) Classes techniques explicitement nommées dans l'explication.\n if (preg_match_all('/\\b[A-Z][A-Za-z0-9_]*(?:Model|Service|Controller)\\b/', $summary, $m)) {\n foreach ($m[0] as $symbol) {\n if ($this->resolveSymbolPaths($root, $symbol)) $add(['type'=>'symbol','value'=>$symbol]);\n }\n }\n\n // 4) Si l'intention est publique et aucune route n'a été trouvée par le\n // scoring, chercher un contrôleur non-admin dont le nom correspond à un\n // terme fonctionnel présent dans la demande (ex. Slideshow/diaporama).\n if ($publicIntent && !$hints) {\n foreach ($this->terms($text) as $term) {\n if (mb_strlen($term) < 5) continue;\n foreach ($this->resolveSymbolPaths($root, ucfirst($term).'Controller') as $path) {\n $add(['type'=>'path','value'=>$path]);\n if (count($hints) >= 4) break 2;\n }\n }\n }\n\n // v2.2.9 — une demande tardive de recherche textuelle devient une\n // opération du Code Explorer. On ne transforme que les termes réellement\n // cités dans summary afin d'éviter les recherches génériques inventées.\n foreach ($this->extractRequestedSearchTerms($summary) as $term) {\n $add(['type'=>'search','value'=>$term]);\n }\n if (preg_match('/\b(service|services)\b.{0,80}\b(fichier|fichiers|upload|uploads|media|stockage|storage)\b/iu', $summary)\n || preg_match('/\b(fichier|fichiers|upload|uploads|media|stockage|storage)\b.{0,80}\b(service|services)\b/iu', $summary)) {\n $add(['type'=>'service','value'=>'file-management']);\n }\n\n return array_slice($hints, 0, 12);\n }\n\n /**\n * Résout les besoins structurels exprimés par l'IA : schéma de tables,\n * migrations existantes et cycle backup/install/rollback. Ces dépendances\n * sont converties en cibles déterministes ; aucune écriture n'est réalisée.\n */\n public function inferStructuralDependencies(string $request,string $summary,array $alreadySeen=[]): array\n {\n $root=rtrim($this->projectRoot,DIRECTORY_SEPARATOR);\n $text=trim($request.' '.$summary);\n if($text==='') return [];\n $lower=$this->ascii(mb_strtolower($text));\n $hints=[];$seen=[];\n foreach($alreadySeen as $hint){\n if(is_array($hint)) $seen[strtolower((string)($hint['type']??'').'|'.(string)($hint['value']??''))]=true;\n }\n $add=static function(string $type,string $value) use (&$hints,&$seen): void {\n $value=trim($value); if($value==='') return;\n $key=strtolower($type.'|'.$value); if(isset($seen[$key])) return;\n $seen[$key]=true; $hints[]=['type'=>$type,'value'=>$value];\n };\n\n // Fichiers/classes explicitement nommés.\n if(preg_match_all('~(?:app|public/assets)/[A-Za-z0-9_./-]+(?:\\\\.php|\\\\.js|\\\\.css|\\\\.json|\\\\.md)?~i',$summary,$m)){\n foreach($m[0] as $path){$path=trim(str_replace('\\\\\\\\','/',$path),'/');if($this->readRelative($root,$path)!==null)$add('path',$path);}\n }\n if(preg_match_all('/\\b[A-Z][A-Za-z0-9_]*(?:Model|Service|Controller)\\b/',$summary,$m)){\n foreach($m[0] as $symbol){\n if(!$this->resolveSymbolPaths($root,$symbol)) continue;\n $add('symbol',$symbol);\n if(str_ends_with($symbol,'Model')){\n $table=$this->resolveModelTable($root,$symbol);\n if($table!==null){\n $add('schema',$table);\n $add('migration',$table);\n }\n }\n }\n }\n\n // Tables explicitement nommées : dev_xxx, noms entre backticks, ou\n // mentions proches de \"table / colonnes / schéma / migration\".\n $tables=[];\n if(preg_match_all('/`([a-z][a-z0-9_]{2,})`/i',$summary,$m)) $tables=array_merge($tables,$m[1]);\n if(preg_match_all('/\\\\b(dev_[a-z0-9_]+|[a-z][a-z0-9_]*_[a-z0-9_]+)\\\\b/i',$summary,$m)) $tables=array_merge($tables,$m[1]);\n // Tables simples comme `media` : uniquement dans une formulation\n // structurelle explicite pour éviter les faux positifs.\n if(preg_match_all('/\\btable\\s+[`\"\\']?([a-z][a-z0-9_]{2,})[`\"\\']?/ui',$text,$m)) $tables=array_merge($tables,$m[1]);\n if(preg_match_all('/\\b(?:table|schema|schéma|colonnes?|columns?|index|indexes|migration(?:s)?)(?:\\s+(?:sql|réel|reel|exacte?|existantes?)){0,3}\\s+(?:de|du|pour|sur|concernant)\\s+[`\"\\']?([a-z][a-z0-9_]{2,})[`\"\\']?/ui',$text,$m)) $tables=array_merge($tables,$m[1]);\n $tables=array_values(array_unique(array_map('strtolower',$tables)));\n $wantsSchema=preg_match('/\\\\b(schema|schéma|structure|colonnes?|columns?|index|indexes)\\\\b/ui',$text)===1;\n $wantsMigration=preg_match('/\\\\b(migration|migrations|forge)\\\\b/ui',$text)===1;\n foreach($tables as $table){\n // v2.3.5 — les candidats issus du texte libre (notamment les mots\n // avec underscore) doivent correspondre à une table réellement\n // connectée avant de devenir une dépendance BDD.\n if(!$this->isExistingDatabaseTable($table)) continue;\n if($wantsSchema) $add('schema',$table);\n else $add('table',$table);\n if($wantsMigration) $add('migration',$table);\n }\n\n // Cycle d'installation / sauvegarde / rollback.\n if(preg_match('/\\\\b(backup|backups|sauvegarde|sauvegardes)\\\\b/ui',$text)) $add('backup','development');\n if(preg_match('/\\\\b(install|installation|installer|migration runner|migrations)\\\\b/ui',$text)) $add('install','development');\n if(preg_match('/\\\\b(rollback|restore|restauration|restaurer)\\\\b/ui',$text)) $add('rollback','development');\n\n // La demande du \"début complet\" d'un service signifie qu'il faut le\n // relire intégralement : symbol/path le garantit dans le contexte pinned.\n if(str_contains($lower,'developmentproposalservice') && $this->readRelative($root,'app/Services/DevelopmentProposalService.php')!==null){\n $add('path','app/Services/DevelopmentProposalService.php');\n }\n\n return array_slice($hints,0,16);\n }\n\n /**\n * Valide uniquement les noms de tables déduits depuis du texte libre.\n * Les dépendances structurées explicites restent conservées par leur\n * protocole ; cette vérification évite surtout les faux positifs comme\n * needs_context ou migration_path.\n */\n private function isExistingDatabaseTable(string $table): bool\n {\n static $available=null;\n $table=preg_replace('/[^A-Za-z0-9_]/','',$table);\n if($table==='') return false;\n if($available===null){\n $available=[];\n try{\n foreach(db_connect()->listTables() as $name)\n $available[strtolower((string)$name)]=true;\n }catch(\\Throwable){\n return false;\n }\n }\n return isset($available[strtolower($table)]);\n }\n\n /**\n * Expansion ciblée demandée par le moteur de proposition.\n * Les indices viennent d'une première analyse IA, mais toutes les lectures\n * sont résolues et vérifiées localement avant d'être injectées.\n */\n public function exploreDependencies(string $request, array $hints): string\n {\n $root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR);\n $selected = [];\n $contents = [];\n $chain = [];\n $normalized = $this->normalizeDependencyHints($hints);\n if (!$normalized) return '';\n\n // Résolution déterministe et prioritaire : les dépendances explicites sont\n // épinglées avant toute recherche textuelle. Une phrase IA n'est jamais\n // transformée aveuglément en dizaines de termes génériques.\n foreach ($normalized as $hint) {\n $type = $hint['type'];\n $value = $hint['value'];\n if ($type === 'path') {\n $this->pinPath($root, $value, $selected, $contents, $chain, 'Chemin exact');\n } elseif ($type === 'symbol') {\n foreach ($this->resolveSymbolPaths($root, $value) as $path) {\n $this->pinPath($root, $path, $selected, $contents, $chain, 'Symbole '.$value);\n }\n } elseif ($type === 'method') {\n foreach ($this->resolveMethodPaths($root, $value) as $path) {\n $this->pinPath($root, $path, $selected, $contents, $chain, 'Méthode '.$value);\n }\n } elseif ($type === 'route') {\n $routes = $this->readRelative($root, 'app/Config/Routes.php');\n if ($routes !== null) {\n $this->pinPath($root, 'app/Config/Routes.php', $selected, $contents, $chain, 'Route '.$value);\n foreach ($this->findRoutesByUri($routes, trim($value, '/')) as $route) {\n $controllerPath = $this->resolveControllerPath($root, $route['controller']);\n $this->pinPath($root, $controllerPath, $selected, $contents, $chain, 'Contrôleur de route '.$value);\n $controller = $contents[$controllerPath] ?? '';\n $block = $this->extractMethodBlock($controller, $route['method']);\n foreach ($this->extractViews($block ?: $controller) as $view) {\n if (!str_contains($view, '$') && !str_contains($view, '{')) {\n $this->pinPath($root, 'app/Views/'.trim($view, '/').'.php', $selected, $contents, $chain, 'Vue de route '.$value);\n }\n }\n }\n }\n } elseif (in_array($type,['table','schema','migration'],true)) {\n foreach ($this->resolveTablePaths($root, $value) as $path) {\n $this->pinPath($root, $path, $selected, $contents, $chain, strtoupper($type).' '.$value);\n }\n } elseif (in_array($type,['backup','install','rollback'],true)) {\n foreach ($this->resolveLifecyclePaths($root,$type,$value) as $path) {\n $this->pinPath($root,$path,$selected,$contents,$chain,strtoupper($type).' '.$value);\n }\n } elseif ($type === 'search') {\n foreach ($this->resolveSearchPaths($root,$value) as $path) {\n $this->pinPath($root,$path,$selected,$contents,$chain,'Recherche exacte '.$value);\n }\n } elseif ($type === 'service') {\n foreach ($this->resolveServicePaths($root,$value) as $path) {\n $this->pinPath($root,$path,$selected,$contents,$chain,'Service '.$value);\n }\n }\n if (count($selected) >= 14) break;\n }\n\n if (!$selected) return '';\n $out = \"\\n\\n=== EXPANSION STRUCTURÉE DES DÉPENDANCES — LECTURE SEULE ===\\n\";\n $out .= \"Les fichiers ci-dessous ont été résolus par chemin, symbole, méthode, route ou table réels. Ils sont prioritaires.\\n\";\n foreach (array_values(array_unique($chain)) as $step) $out .= '- '.$step.\"\\n\";\n $out .= \"\\nFICHIERS DE DÉPENDANCES PRIORITAIRES :\\n\";\n foreach ($selected as $path) {\n $out .= \"\\n--- {$path} ---\\n\".$this->targetContent($contents[$path] ?? '').\"\\n\";\n if (strlen($out) >= 260000) break;\n }\n $out .= \"\\nRÈGLE : ces dépendances sont déjà lues depuis le disque réel. Ne demande pas une nouvelle exploration d'un élément présent ci-dessus.\\n\";\n return AiTextEncodingService::slice($out, 0, 260000);\n }\n\n /**\n * Contexte final verrouillé pour la génération du diff.\n * Contrairement au contexte exploratoire, les dépendances explicites sont\n * relues juste avant l'appel final et ne passent ni par snippet() ni par la\n * limite globale de 180 Ko. Chaque fichier est accompagné de sa taille et\n * d'un marqueur COMPLET vérifiable.\n */\n public function explorePinnedDependencies(string $request, array $hints): string\n {\n $root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR);\n $selected = [];\n $contents = [];\n $chain = [];\n $executed = [];\n $normalized = $this->normalizeDependencyHints($hints);\n if (!$normalized) return '';\n\n // v2.4.5 — les chemins exacts demandés/autorisés sont obligatoires.\n // Ils sont lus et placés AVANT toutes les dépendances secondaires.\n $mandatoryHints = [];\n $secondaryHints = [];\n foreach ($normalized as $hint) {\n if (($hint['type'] ?? '') === 'path') $mandatoryHints[] = $hint;\n else $secondaryHints[] = $hint;\n }\n\n $mandatoryPaths = [];\n foreach ($mandatoryHints as $hint) {\n $value = (string)($hint['value'] ?? '');\n $before = count($selected);\n $this->pinPath($root, $value, $selected, $contents, $chain, 'PIN path obligatoire');\n\n // v2.4.7 — un path explicite est obligatoire même si le fichier\n // avait déjà été découvert par une dépendance précédente. La\n // v2.4.5 ne promouvait que le dernier fichier *nouvellement*\n // ajouté à $selected, ce qui rendait l'obligation dépendante de\n // l'ordre d'exploration.\n $exactPath = trim(str_replace('\\\\', '/', $value), '/');\n if ($exactPath !== '' && array_key_exists($exactPath, $contents)) {\n $mandatoryPaths[$exactPath] = true;\n }\n\n $executed[] = 'path: '.$value.' => '.max(0, count($selected) - $before).\n ' nouveau(x) fichier(s), '.(isset($mandatoryPaths[$exactPath]) ? 'cible exacte promue OBLIGATOIRE' : 'aucun fichier exact résolu');\n }\n\n foreach ($secondaryHints as $hint) {\n $type=$hint['type']; $value=$hint['value']; $before=count($selected);\n if ($type==='symbol') {\n foreach($this->resolveSymbolPaths($root,$value) as $path) $this->pinPath($root,$path,$selected,$contents,$chain,'PIN symbol '.$value);\n } elseif ($type==='method') {\n foreach($this->resolveMethodPaths($root,$value) as $path) $this->pinPath($root,$path,$selected,$contents,$chain,'PIN method '.$value);\n } elseif ($type==='route') {\n $routes=$this->readRelative($root,'app/Config/Routes.php');\n if($routes!==null){\n $this->pinPath($root,'app/Config/Routes.php',$selected,$contents,$chain,'PIN route '.$value);\n foreach($this->findRoutesByUri($routes,trim($value,'/')) as $route){\n $controllerPath=$this->resolveControllerPath($root,$route['controller']);\n $this->pinPath($root,$controllerPath,$selected,$contents,$chain,'PIN controller route '.$value);\n $controller=$contents[$controllerPath]??'';\n $block=$this->extractMethodBlock($controller,$route['method']);\n foreach($this->extractViews($block?:$controller) as $view){\n if(!str_contains($view,'$')&&!str_contains($view,'{')) $this->pinPath($root,'app/Views/'.trim($view,'/').'.php',$selected,$contents,$chain,'PIN view route '.$value);\n }\n }\n }\n } elseif (in_array($type,['table','schema','migration'],true)) {\n foreach($this->resolveTablePaths($root,$value) as $path) $this->pinPath($root,$path,$selected,$contents,$chain,'PIN '.$type.' '.$value);\n } elseif (in_array($type,['backup','install','rollback'],true)) {\n foreach($this->resolveLifecyclePaths($root,$type,$value) as $path) $this->pinPath($root,$path,$selected,$contents,$chain,'PIN '.$type.' '.$value);\n } elseif ($type==='search') {\n foreach($this->resolveSearchPaths($root,$value) as $path) $this->pinPath($root,$path,$selected,$contents,$chain,'PIN search '.$value);\n } elseif ($type==='service') {\n foreach($this->resolveServicePaths($root,$value) as $path) $this->pinPath($root,$path,$selected,$contents,$chain,'PIN service '.$value);\n }\n $executed[]=$type.': '.$value.' => '.max(0,count($selected)-$before).' nouveau(x) fichier(s) épinglé(s)';\n }\n if(!$selected) return '';\n\n $out=\"\\n\\n=== CONTEXTE FINAL VERROUILLE evolution — SOURCE DE VERITE ===\\n\";\n $out.=\"v2.4.7 : tout chemin exact explicite et resolu est promu OBLIGATOIRE, meme s'il avait deja ete decouvert. Les blocs PINNED sont injectes entiers et jamais tronques.\\n\";\n $out.=\"Ces fichiers viennent d'etre relus sur le disque juste avant la generation du diff. Les marqueurs COMPLET signifient que le contenu entre BEGIN/END est integral.\\n\";\n $out.=\"\\nPLAN D'EXPLORATION EXECUTE PAR evolution :\\n\";\n foreach(array_values(array_unique($executed)) as $step) $out.='- '.$step.\"\\n\";\n foreach(array_values(array_unique($chain)) as $step) $out.='- '.$step.\"\\n\";\n\n $footer=\"\\nREGLE FINALE : ne demande jamais de relire un fichier marque COMPLET ci-dessus. Produis maintenant files[]/database ou indique uniquement une dependance concrete absente.\\n\";\n $omitted=[];\n\n // 1) Contexte obligatoire : tous les path explicites doivent tenir EN ENTIER.\n if ($mandatoryPaths) {\n $out.=\"\\n=== CONTEXTE OBLIGATOIRE — CHEMINS EXACTS AUTORISES ===\\n\";\n }\n foreach(array_keys($mandatoryPaths) as $path){\n $content=$contents[$path]??''; $size=strlen($content);\n if($size>self::MAX_PINNED_FILE){\n throw new \\RuntimeException(\n 'Code Explorer : fichier obligatoire trop volumineux pour la garantie COMPLET : '.\n $path.' ('.$size.' octets, limite '.self::MAX_PINNED_FILE.').'\n );\n }\n $hash=hash('sha256',$content);\n $block=\"\\n--- BEGIN PINNED {$path} | {$size} octets | sha256={$hash} | COMPLET ---\\n\".$content.\"\\n--- END PINNED {$path} | COMPLET ---\\n\";\n $needed=strlen($out)+strlen($block)+strlen($footer);\n if($needed>self::MAX_PINNED_CONTEXT){\n throw new \\RuntimeException(\n 'Code Explorer : budget de contexte insuffisant pour injecter EN ENTIER les fichiers obligatoires. '.\n 'Bloc refuse : '.$path.' ('.$size.' octets). Budget '.self::MAX_PINNED_CONTEXT.\n ' octets, besoin minimal '.$needed.' octets. Aucun fichier obligatoire n\\'a ete tronque.'\n );\n }\n $out.=$block;\n }\n\n // 2) Contexte secondaire : uniquement des blocs atomiques complets.\n // On ne coupe plus jamais un bloc avec substr().\n $secondaryPaths=array_values(array_filter($selected, static fn($path) => !isset($mandatoryPaths[$path])));\n if($secondaryPaths){\n $section=\"\\n=== CONTEXTE SECONDAIRE — DEPENDANCES RESOLUES ===\\n\";\n if(strlen($out)+strlen($section)+strlen($footer)<=self::MAX_PINNED_CONTEXT) $out.=$section;\n }\n foreach($secondaryPaths as $path){\n $content=$contents[$path]??''; $size=strlen($content);\n // v2.6.0 — les dépendances secondaires servent à COMPRENDRE, pas\n // encore à réécrire le fichier. On envoie donc un extrait pertinent\n // et borné. Si le modèle doit modifier ce fichier, il doit demander\n // explicitement type=path : il sera alors relu intégralement au cycle\n // suivant et promu dans CONTEXTE OBLIGATOIRE.\n $snippet=$this->snippet($content,$this->terms($request));\n if(strlen($snippet)>self::MAX_SECONDARY_SNIPPET){\n $snippet=AiTextEncodingService::slice($snippet,0,self::MAX_SECONDARY_SNIPPET).\"\\n...[extrait secondaire tronqué]...\";\n }\n $hash=hash('sha256',$content);\n $block=\"\\n--- CONTEXT EXCERPT {$path} | {$size} octets | sha256={$hash} | NON_COMPLET ---\\n\".$snippet.\"\\n--- END CONTEXT EXCERPT {$path} ---\\n\";\n if(strlen($out)+strlen($block)+strlen($footer)>self::MAX_PINNED_CONTEXT){\n $omitted[]=$path;\n continue;\n }\n $out.=$block;\n }\n\n if($omitted){\n $note=\"\\nDEPENDANCES SECONDAIRES NON INJECTEES FAUTE DE BUDGET : \".implode(', ',array_slice($omitted,0,12)).\".\\n\";\n if(strlen($out)+strlen($note)+strlen($footer)<=self::MAX_PINNED_CONTEXT) $out.=$note;\n }\n\n if(strlen($out)+strlen($footer)>self::MAX_PINNED_CONTEXT){\n // Cas defensif : le header seul ne doit jamais rendre le payload invalide.\n throw new \\RuntimeException('Code Explorer : en-tete du contexte verrouille trop volumineux pour le budget disponible.');\n }\n\n // v2.4.7 — garde-fou interne : chaque path explicite promu obligatoire\n // doit réellement posséder son BEGIN/END COMPLET dans le payload qui\n // sera remis au Finalizer. Cette vérification est indépendante de\n // l'ordre d'exploration et donne un diagnostic précis avant l'appel IA.\n foreach(array_keys($mandatoryPaths) as $path){\n $content=$contents[$path]??'';\n $size=strlen($content);\n $hash=hash('sha256',$content);\n $begin=\"--- BEGIN PINNED {$path} | {$size} octets | sha256={$hash} | COMPLET ---\";\n $end=\"--- END PINNED {$path} | COMPLET ---\";\n if(!str_contains($out,$begin) || !str_contains($out,$end)){\n throw new \\RuntimeException(\n 'Code Explorer : chemin explicite obligatoire non expose integralement avant Finalizer : '.\n $path.' ('.$size.' octets, sha256='.$hash.').'\n );\n }\n }\n\n return $out.$footer;\n }\n\n /**\n * v2.4.1 — matérialise les dépendances autorisées (route/search/service/\n * symbol/...) en chemins exacts réellement résolus. Ces chemins peuvent\n * ensuite bénéficier de la garantie v2.3.6 (octets + SHA-256 + COMPLET).\n */\n public function materializeResolvedPathHints(string $request, array $hints): array\n {\n if (!$hints) return [];\n $pinned=$this->explorePinnedDependencies($request,$hints);\n if ($pinned==='') return [];\n $out=[];$seen=[];\n if (preg_match_all('/--- BEGIN PINNED ([^|\\r\\n]+?) \\| \\d+ octets \\| sha256=[a-f0-9]{64} \\| COMPLET ---/i',$pinned,$m)) {\n foreach ($m[1] as $path) {\n $path=trim(str_replace('\\\\','/',(string)$path),'/ ');\n if ($path===''||isset($seen[$path])) continue;\n $seen[$path]=true;$out[]=['type'=>'path','value'=>$path];\n }\n }\n return $out;\n }\n\n /**\n * v2.3.6 — garantit qu'un path explicite est présent COMPLET dans le\n * contexte épinglé et que taille + SHA-256 correspondent aux octets relus\n * sur le disque. Une simple mention du chemin ne suffit jamais.\n */\n public function assertExactPathsFullyPinned(array $hints, string $context): void\n {\n $missing=[];\n foreach($this->normalizeDependencyHints($hints) as $hint){\n if(($hint['type']??'')!=='path') continue;\n $path=trim(str_replace('\\\\','/',(string)($hint['value']??'')),'/');\n if($path===''||!$this->allowed($path)) continue;\n $full=$this->projectRoot.$path;\n if(!is_file($full)) continue;\n $content=@file_get_contents($full);\n if(!is_string($content)) throw new \\RuntimeException('Code Explorer : lecture impossible du fichier requis : '.$path);\n $size=strlen($content); $hash=hash('sha256',$content);\n $begin='--- BEGIN PINNED '.$path.' | '.$size.' octets | sha256='.$hash.' | COMPLET ---';\n $end='--- END PINNED '.$path.' | COMPLET ---';\n if(!str_contains($context,$begin)||!str_contains($context,$end)){\n $missing[]=$path.' ('.$size.' octets, sha256='.$hash.')';\n }\n }\n if($missing){\n throw new \\RuntimeException('Code Explorer : contenu réel COMPLET non garanti dans le payload pour : '.implode(', ',array_slice($missing,0,8)).'.');\n }\n }\n\n private function normalizeDependencyHints(array $hints): array\n {\n $out=[];\n foreach ($hints as $hint) {\n if (is_array($hint)) {\n $type=strtolower(trim((string)($hint['type']??'')));\n $value=trim((string)($hint['path']??$hint['value']??$hint['name']??$hint['method']??$hint['route']??$hint['table']??''));\n if (in_array($type,['path','symbol','method','route','table','schema','migration','backup','install','rollback','search','service'],true) && $value!=='') $out[]=['type'=>$type,'value'=>$value];\n continue;\n }\n $text=trim((string)$hint); if($text==='') continue;\n if (preg_match_all('~(?:app|public/assets)/[A-Za-z0-9_./-]+\\.(?:php|js|css|json|md)~i',$text,$m)) {\n foreach($m[0] as $v) $out[]=['type'=>'path','value'=>$v];\n }\n if (preg_match_all('/\\b[A-Z][A-Za-z0-9_]*(?:Model|Service|Controller)\\b/',$text,$m)) foreach($m[0] as $v) $out[]=['type'=>'symbol','value'=>$v];\n if (preg_match_all('/\\b([A-Za-z_][A-Za-z0-9_]*)\\s*\\(\\)/',$text,$m)) foreach($m[1] as $v) $out[]=['type'=>'method','value'=>$v];\n if (preg_match('~(?:route\\s+GET\\s+|route\\s+POST\\s+|route\\s+)(/?[A-Za-z0-9_./:-]+)~i',$text,$m)) $out[]=['type'=>'route','value'=>$m[1]];\n if (preg_match_all('/\\b[a-z][a-z0-9_]{2,}\\b/',$text,$m)) {\n foreach($m[0] as $v) if(str_contains($v,'_')) $out[]=['type'=>'table','value'=>$v];\n }\n }\n $seen=[];$clean=[];\n foreach($out as $h){$k=$h['type'].'|'.strtolower($h['value']);if(isset($seen[$k]))continue;$seen[$k]=1;$clean[]=$h;}\n return array_slice($clean,0,24);\n }\n\n /**\n * Extrait uniquement les termes explicitement demandés pour une recherche\n * globale (\"occurrence de X\", \"chercher X\", \"rechercher X\").\n */\n /**\n * v2.3.2 — transforme les demandes d'exploration en prose en dépendances\n * structurées. Aucune cible n'est inventée : les chemins sont vérifiés et les\n * tables/termes doivent être explicitement présents dans le texte.\n */\n private function normalizeNaturalExplorationPlan(string $summary): array\n {\n $root=rtrim($this->projectRoot,DIRECTORY_SEPARATOR);\n $hints=[];$seen=[];\n $add=static function(string $type,string $value) use (&$hints,&$seen): void {\n $value=trim($value,\" \\t\\n\\r\\0\\x0B`\\\"\");\n if($value==='') return;\n $key=mb_strtolower($type.'|'.$value); if(isset($seen[$key])) return;\n $seen[$key]=true; $hints[]=['type'=>$type,'value'=>$value];\n };\n\n // Chemins explicites, même lorsqu'ils sont seuls dans un bloc de code.\n if(preg_match_all('~(?:app|public/assets)/[A-Za-z0-9_./-]+(?:\\\\.php|\\\\.js|\\\\.css|\\\\.json|\\\\.md)?~i',$summary,$m)){\n foreach($m[0] as $path){\n $path=trim(str_replace('\\\\\\\\','/',$path),'/');\n if($this->readRelative($root,$path)!==null) $add('path',$path);\n }\n }\n\n // « schéma réel de media », « structure réelle de la table media ».\n if(preg_match_all('/(?:sch[ée]ma|structure)(?:\\\\s+(?:sql|r[ée]el(?:le)?|exacte?)){0,3}\\\\s+(?:de|du|pour)\\\\s+(?:la\\\\s+table\\\\s+)?[`\\\"\\']?([a-z][a-z0-9_]{2,})/iu',$summary,$m))\n foreach($m[1] as $table) $add('schema',strtolower($table));\n if(preg_match_all('/(?:table)\\\\s+[`\\\"\\']?([a-z][a-z0-9_]{2,})[`\\\"\\']?.{0,45}(?:sch[ée]ma|structure|colonnes?|index|contraintes?)/iu',$summary,$m))\n foreach($m[1] as $table) $add('schema',strtolower($table));\n\n // « migrations associées » hérite de la/les tables explicitement citées\n // dans la même demande. Cela couvre le cas « schéma réel de media ; les\n // migrations associées » sans demander au modèle de répéter media.\n $schemaTables=[];\n foreach($hints as $hint) if($hint['type']==='schema') $schemaTables[]=$hint['value'];\n if($schemaTables && preg_match('/\\\\bmigrations?\\\\s+(?:associ[ée]es?|li[ée]es?|correspondantes?|concern[ée]es?)\\\\b/iu',$summary))\n foreach($schemaTables as $table) $add('migration',$table);\n if(preg_match_all('/\\\\bmigrations?\\\\s+(?:de|du|pour|li[ée]es?\\\\s+[àa])\\\\s+[`\\\"\\']?([a-z][a-z0-9_]{2,})/iu',$summary,$m))\n foreach($m[1] as $table) $add('migration',strtolower($table));\n\n // « recherches globales sur A, B, C et D ». Le segment est borné à la\n // phrase/liste concernée puis découpé sur virgules, puces et « et ».\n if(preg_match_all('/(?:recherches?\\\\s+globales?|rechercher\\\\s+globalement|chercher\\\\s+globalement)\\\\s+(?:sur|de|pour)?\\\\s*([^\\\\n.;]{2,500})/iu',$summary,$m)){\n foreach($m[1] as $chunk){\n $chunk=preg_replace('/\\\\s+(?:ainsi\\\\s+que|et)\\\\s+/iu',',',$chunk)??$chunk;\n foreach(preg_split('/\\\\s*[,•]\\\\s*/u',$chunk)?:[] as $term){\n $term=trim($term,\" \\t\\n\\r\\0\\x0B`\\\"\");\n $term=preg_replace('/^(?:les?|la|des?|toutes?\\\\s+les?)\\\\s+/iu','',$term)??$term;\n if($term!=='' && mb_strlen($term)>=2 && mb_strlen($term)<=80) $add('search',$term);\n }\n }\n }\n\n // Termes techniques explicitement cités après une intention de recherche.\n if(preg_match('/(?:recherches?\\\\s+globales?|occurrences?|chercher|rechercher)/iu',$summary)){\n foreach(['MediaModel','stored_name',\"WRITEPATH.'uploads'\",'WRITEPATH.\"uploads\"','unlink','delete()','delete','uploads/'] as $candidate)\n if(stripos($summary,$candidate)!==false) $add('search',$candidate);\n }\n\n return array_slice($hints,0,16);\n }\n\n private function extractRequestedSearchTerms(string $summary): array\n {\n $terms=[];\n $patterns=[\n '/(?:occurrences?|recherche(?:r)?|chercher|trouver)\\s+(?:de|du|des|pour)?\\s*([`\\'\\\"]?)([^\\n,;•]{2,80})\\1/iu',\n ];\n foreach($patterns as $pattern){\n if(!preg_match_all($pattern,$summary,$matches,PREG_SET_ORDER)) continue;\n foreach($matches as $match){\n $chunk=trim((string)($match[2]??''));\n $chunk=preg_replace('/^(?:toute|toutes|tout|les|la|le|une|un)\\s+/iu','',$chunk)??$chunk;\n // Une liste \"A, B et C\" est traitée plus bas via les tokens cités.\n if($chunk!=='' && mb_strlen($chunk)<=80) $terms[]=$chunk;\n }\n }\n // Les identifiants/code cités explicitement sont les cibles les plus sûres.\n if(preg_match_all('/`([^`]{2,80})`/u',$summary,$m)) foreach($m[1] as $v) $terms[]=trim($v);\n // Cas fréquent sans backticks dans une liste d'occurrences.\n if(preg_match('/occurrences?/iu',$summary)){\n foreach(['MediaModel','delete','unlink',\"WRITEPATH.'uploads'\",'WRITEPATH.\"uploads\"','path','stored_name'] as $candidate){\n if(stripos($summary,$candidate)!==false) $terms[]=$candidate;\n }\n }\n $clean=[];$seen=[];\n foreach($terms as $term){\n $term=trim($term,\" \\t\\n\\r\\0\\x0B`'\\\"\");\n if($term==='' || mb_strlen($term)<2 || mb_strlen($term)>80) continue;\n $key=mb_strtolower($term); if(isset($seen[$key])) continue;\n $seen[$key]=true; $clean[]=$term;\n }\n return array_slice($clean,0,10);\n }\n\n /** Recherche littérale bornée aux racines autorisées, sans shell ni regex utilisateur. */\n private function resolveSearchPaths(string $root,string $needle): array\n {\n $needle=trim($needle); if($needle==='' || mb_strlen($needle)>80) return [];\n $matches=[];\n foreach($this->roots as $relativeRoot){\n $base=$root.DIRECTORY_SEPARATOR.str_replace('/',DIRECTORY_SEPARATOR,$relativeRoot);\n if(!is_dir($base)) continue;\n $it=new \\RecursiveIteratorIterator(new \\RecursiveDirectoryIterator($base,\\FilesystemIterator::SKIP_DOTS));\n foreach($it as $file){\n if(!$file->isFile() || $file->getSize()>self::MAX_FILE_SIZE) continue;\n $ext=strtolower($file->getExtension()); if(!in_array($ext,$this->extensions,true)) continue;\n $path=$this->relative($file->getPathname()); if(!$this->allowed($path)) continue;\n $content=@file_get_contents($file->getPathname()); if(!is_string($content)) continue;\n $count=substr_count(mb_strtolower($content),mb_strtolower($needle)); if($count<1) continue;\n $score=min($count,20)*3;\n if(str_starts_with($path,'app/Controllers/')) $score+=12;\n elseif(str_starts_with($path,'app/Models/')) $score+=11;\n elseif(str_starts_with($path,'app/Services/')) $score+=10;\n elseif(str_starts_with($path,'app/Database/Migrations/')) $score+=8;\n if(str_contains(mb_strtolower(basename($path)),mb_strtolower(preg_replace('/[^A-Za-z0-9_]/','',$needle)??$needle))) $score+=8;\n $matches[]=['path'=>$path,'score'=>$score];\n }\n }\n usort($matches,static fn(array $a,array $b): int => ($b['score']<=>$a['score']) ?: strcmp($a['path'],$b['path']));\n return array_column(array_slice($matches,0,6),'path');\n }\n\n /** Résout les services de gestion de fichiers sans inventer de classe. */\n private function resolveServicePaths(string $root,string $value): array\n {\n $serviceRoot=$root.DIRECTORY_SEPARATOR.'app/Services'; if(!is_dir($serviceRoot)) return [];\n $tokens=$value==='file-management'\n ? ['file','media','upload','storage','image','document','delete','unlink','path','stored_name']\n : array_values(array_filter(preg_split('/[^A-Za-z0-9_]+/',mb_strtolower($value))?:[]));\n $ranked=[];\n foreach(new \\DirectoryIterator($serviceRoot) as $file){\n if(!$file->isFile() || strtolower($file->getExtension())!=='php' || $file->getSize()>self::MAX_FILE_SIZE) continue;\n $path='app/Services/'.$file->getFilename(); $content=$this->readRelative($root,$path); if($content===null) continue;\n $hay=mb_strtolower($file->getFilename().' '.$content); $score=0;\n foreach($tokens as $token) if($token!=='' && str_contains($hay,mb_strtolower($token))) $score++;\n if($score>0) $ranked[]=['path'=>$path,'score'=>$score];\n }\n usort($ranked,static fn(array $a,array $b): int => ($b['score']<=>$a['score']) ?: strcmp($a['path'],$b['path']));\n return array_column(array_slice($ranked,0,5),'path');\n }\n\n private function pinPath(string $root,string $path,array &$selected,array &$contents,array &$chain,string $reason): void\n {\n $path=trim(str_replace('\\\\','/',$path),'/');\n $content=$this->readRelative($root,$path); if($content===null)return;\n $this->addFile($selected,$contents,$path,$content); $chain[]=$reason.' -> '.$path;\n }\n\n /** Lit la propriété protected $table d'un Model réel. */\n private function resolveModelTable(string $root,string $symbol): ?string\n {\n if(!str_ends_with($symbol,'Model')) return null;\n foreach($this->resolveSymbolPaths($root,$symbol) as $path){\n $content=$this->readRelative($root,$path);\n if($content===null) continue;\n if(preg_match('/protected\\s+\\$table\\s*=\\s*[\\'\"]([A-Za-z0-9_]+)[\\'\"]\\s*;/i',$content,$m)){\n $table=preg_replace('/[^A-Za-z0-9_]/','',(string)$m[1]);\n if($table!=='') return $table;\n }\n }\n return null;\n }\n\n private function resolveSymbolPaths(string $root,string $symbol): array\n {\n $symbol=preg_replace('/[^A-Za-z0-9_]/','',$symbol); if($symbol==='')return [];\n $roots=['app/Models','app/Services','app/Controllers'];$matches=[];\n foreach($roots as $base){$dir=$root.DIRECTORY_SEPARATOR.str_replace('/',DIRECTORY_SEPARATOR,$base);if(!is_dir($dir))continue;$it=new \\RecursiveIteratorIterator(new \\RecursiveDirectoryIterator($dir,\\FilesystemIterator::SKIP_DOTS));foreach($it as $f){if($f->isFile()&&strcasecmp($f->getFilename(),$symbol.'.php')===0)$matches[]=$this->relative($f->getPathname());}}\n return array_values(array_unique($matches));\n }\n\n private function resolveMethodPaths(string $root,string $method): array\n {\n $method=preg_replace('/[^A-Za-z0-9_]/','',$method); if($method==='')return [];$matches=[];\n $dir=$root.DIRECTORY_SEPARATOR.'app/Controllers'; if(!is_dir($dir))return [];\n $it=new \\RecursiveIteratorIterator(new \\RecursiveDirectoryIterator($dir,\\FilesystemIterator::SKIP_DOTS));\n foreach($it as $f){if(!$f->isFile()||strtolower($f->getExtension())!=='php')continue;$c=@file_get_contents($f->getPathname());if(is_string($c)&&preg_match('/function\\s+'.preg_quote($method,'/').'\\s*\\(/i',$c))$matches[]=$this->relative($f->getPathname());}\n return array_slice(array_values(array_unique($matches)),0,6);\n }\n\n private function resolveLifecyclePaths(string $root,string $type,string $value=''): array\n {\n $needles=match($type){\n 'backup'=>['backup','backups','DevelopmentDatabaseBackupService'],\n 'rollback'=>['rollback','restore','restoreFiles','DevelopmentDatabaseBackupService'],\n default=>['install','migrations','DevelopmentProposalService'],\n };\n $matches=[];\n // Le service de proposition est la source de vérité du cycle\n // prévalidation -> backup -> installation -> migration -> rollback.\n foreach(['app/Services/DevelopmentProposalService.php','app/Services/DevelopmentDatabaseBackupService.php'] as $known){\n $c=$this->readRelative($root,$known);\n if($c!==null){foreach($needles as $n){if(stripos($c,$n)!==false){$matches[]=$known;break;}}}\n }\n foreach(['app/Services','app/Controllers/Admin'] as $base){\n $dir=$root.DIRECTORY_SEPARATOR.str_replace('/',DIRECTORY_SEPARATOR,$base); if(!is_dir($dir)) continue;\n $it=new \\RecursiveIteratorIterator(new \\RecursiveDirectoryIterator($dir,\\FilesystemIterator::SKIP_DOTS));\n foreach($it as $f){\n if(!$f->isFile()||strtolower($f->getExtension())!=='php') continue;\n $c=@file_get_contents($f->getPathname()); if(!is_string($c)) continue;\n foreach($needles as $n){ if(stripos($c,$n)!==false){$matches[]=$this->relative($f->getPathname());break;} }\n if(count($matches)>=10) break 2;\n }\n }\n return array_slice(array_values(array_unique($matches)),0,10);\n }\n\n private function resolveTablePaths(string $root,string $table): array\n {\n $table=preg_replace('/[^A-Za-z0-9_]/','',$table);if($table==='')return [];$matches=[];\n foreach(['app/Database/Migrations','app/Models'] as $base){$dir=$root.DIRECTORY_SEPARATOR.str_replace('/',DIRECTORY_SEPARATOR,$base);if(!is_dir($dir))continue;$it=new \\RecursiveIteratorIterator(new \\RecursiveDirectoryIterator($dir,\\FilesystemIterator::SKIP_DOTS));foreach($it as $f){if(!$f->isFile()||strtolower($f->getExtension())!=='php')continue;$c=@file_get_contents($f->getPathname());if(is_string($c)&&stripos($c,$table)!==false)$matches[]=$this->relative($f->getPathname());}}\n return array_values(array_unique($matches));\n }\n\n /**\n * Retrouve la route correspondant exactement à la page source connue du\n * Development Engine. Cela permet de continuer l'exploration même lorsque\n * la formulation utilisateur ne contient aucun nom de module ou d'URL.\n */\n private function findRoutesByUri(string $content, string $sourceUri): array\n {\n $sourceUri = trim($sourceUri, '/');\n if ($sourceUri === '') {\n return [];\n }\n\n $matches = [];\n foreach ($this->routeDefinitions($content) as $route) {\n if (trim((string) $route['uri'], '/') !== $sourceUri) {\n continue;\n }\n $route['score'] = 1000;\n $matches[] = $route;\n }\n return $matches;\n }\n\n /**\n * Cherche les routes dont l'URI complet ou la cible correspond aux termes utilisateur.\n * Les groupes CodeIgniter sont reconstruits (préfixe + namespace) afin que\n * \"mises-a-jour\" dans group('admin', ...) devienne réellement\n * \"/admin/mises-a-jour\" et App\\\\Controllers\\\\Admin\\\\Updates.\n */\n private function findMatchingRoutes(string $content, array $terms): array\n {\n $matches = [];\n $asciiRequestTerms = array_values(array_unique(array_filter(array_map(fn($t) => $this->ascii($t), $terms))));\n\n foreach ($this->routeDefinitions($content) as $route) {\n $haystack = mb_strtolower((string) $route['uri'].' '.(string) $route['controller'].'::'.(string) $route['method']);\n $asciiHaystack = $this->ascii($haystack);\n $score = 0;\n foreach ($terms as $term) {\n if ($term !== '' && str_contains($haystack, $term)) {\n $score += 5;\n }\n }\n foreach ($asciiRequestTerms as $term) {\n if ($term !== '' && str_contains($asciiHaystack, $term)) {\n $score += 4;\n }\n }\n if ($score === 0) {\n continue;\n }\n $route['score'] = $score;\n $matches[] = $route;\n }\n\n usort($matches, static fn(array $a, array $b): int => $b['score'] <=> $a['score']);\n return array_slice($matches, 0, 4);\n }\n\n /**\n * Extrait les routes directes et celles placées dans un group() simple.\n * Cette lecture est volontairement déterministe et limitée au format utilisé\n * par evolution afin d'éviter toute invention de namespace ou de chemin.\n */\n private function routeDefinitions(string $content): array\n {\n $routes = [];\n $covered = [];\n\n if (preg_match_all('/\\\\$routes->group\\\\(\\\\s*[\\'\\\"]([^\\'\\\"]+)[\\'\\\"]\\\\s*,\\\\s*(\\\\[[^;]*?\\\\])?\\\\s*,\\\\s*static\\\\s+function\\\\s*\\\\(\\\\$routes\\\\)\\\\s*\\\\{/is', $content, $groups, PREG_OFFSET_CAPTURE)) {\n foreach ($groups[0] as $i => $whole) {\n $groupStart = $whole[1];\n $openBrace = $groupStart + strrpos($whole[0], '{');\n $closeBrace = $this->matchingBrace($content, $openBrace);\n if ($closeBrace === null) {\n continue;\n }\n $prefix = trim((string) $groups[1][$i][0], '/');\n $options = (string) ($groups[2][$i][0] ?? '');\n $namespace = '';\n if (preg_match('/[\\'\\\"]namespace[\\'\\\"]\\\\s*=>\\\\s*[\\'\\\"]([^\\'\\\"]+)[\\'\\\"]/i', $options, $m)) {\n $namespace = trim(str_replace('\\\\\\\\', '\\\\', $m[1]), '\\\\');\n }\n $body = substr($content, $openBrace + 1, $closeBrace - $openBrace - 1);\n foreach ($this->parseRouteCalls($body, $prefix, $namespace) as $route) {\n $routes[] = $route;\n }\n $covered[] = [$groupStart, $closeBrace + 1];\n }\n }\n\n // Routes hors groupes. Les appels déjà compris dans un groupe sont ignorés.\n if (preg_match_all('/\\\\$routes->(get|post|put|patch|delete|options|match)\\\\(\\\\s*[\\'\\\"]([^\\'\\\"]+)[\\'\\\"]\\\\s*,\\\\s*[\\'\\\"]([^\\'\\\"]+)[\\'\\\"]/i', $content, $rows, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {\n foreach ($rows as $row) {\n $offset = $row[0][1];\n $inside = false;\n foreach ($covered as [$a, $b]) {\n if ($offset >= $a && $offset < $b) { $inside = true; break; }\n }\n if ($inside) continue;\n $parsed = $this->normalizeRoute($row[1][0], $row[2][0], $row[3][0], '', '');\n if ($parsed !== null) $routes[] = $parsed;\n }\n }\n\n return $routes;\n }\n\n private function parseRouteCalls(string $body, string $prefix, string $namespace): array\n {\n $routes = [];\n if (!preg_match_all('/\\\\$routes->(get|post|put|patch|delete|options|match)\\\\(\\\\s*[\\'\\\"]([^\\'\\\"]+)[\\'\\\"]\\\\s*,\\\\s*[\\'\\\"]([^\\'\\\"]+)[\\'\\\"]/i', $body, $rows, PREG_SET_ORDER)) {\n return [];\n }\n foreach ($rows as $row) {\n $route = $this->normalizeRoute($row[1], $row[2], $row[3], $prefix, $namespace);\n if ($route !== null) $routes[] = $route;\n }\n return $routes;\n }\n\n private function normalizeRoute(string $verb, string $uri, string $target, string $prefix, string $namespace): ?array\n {\n $target = preg_replace('~/\\\\$\\\\d+.*$~', '', $target) ?? $target;\n if (!str_contains($target, '::')) return null;\n [$controller, $method] = explode('::', $target, 2);\n $controller = trim($controller, '\\\\');\n if ($namespace !== '' && !str_contains($controller, '\\\\')) {\n $controller = $namespace.'\\\\'.$controller;\n }\n $fullUri = trim(trim($prefix, '/').'/'.trim($uri, '/'), '/');\n if ($prefix === '') $fullUri = trim($uri, '/');\n return [\n 'verb' => strtolower($verb),\n 'uri' => $fullUri,\n 'controller' => $controller,\n 'method' => preg_replace('/[^A-Za-z0-9_].*$/', '', $method) ?: $method,\n 'score' => 0,\n ];\n }\n\n private function matchingBrace(string $content, int $openBrace): ?int\n {\n $depth = 0; $inSingle = false; $inDouble = false; $escape = false; $len = strlen($content);\n for ($i = $openBrace; $i < $len; $i++) {\n $c = $content[$i];\n if ($escape) { $escape = false; continue; }\n if (($inSingle || $inDouble) && $c === '\\\\') { $escape = true; continue; }\n if (!$inDouble && $c === \"'\") { $inSingle = !$inSingle; continue; }\n if (!$inSingle && $c === '\"') { $inDouble = !$inDouble; continue; }\n if ($inSingle || $inDouble) continue;\n if ($c === '{') $depth++;\n elseif ($c === '}') {\n $depth--;\n if ($depth === 0) return $i;\n }\n }\n return null;\n }\n\n /**\n * Résout le chemin réel d'un contrôleur.\n *\n * Dans CodeIgniter, une route située dans un group() avec namespace\n * peut contenir simplement \"Development::index\" alors que le fichier\n * réel est app/Controllers/Admin/Development.php. On ne doit donc pas\n * déduire aveuglément app/Controllers/Development.php.\n */\n private function resolveControllerPath(string $root, string $controller): string\n {\n $normalized = str_replace('\\\\', '/', trim($controller, '\\\\'));\n if (str_starts_with($normalized, 'App/Controllers/')) {\n $normalized = substr($normalized, strlen('App/Controllers/'));\n }\n $candidates = [\n 'app/Controllers/' . $normalized . '.php',\n 'app/Controllers/Admin/' . $normalized . '.php',\n ];\n\n foreach (array_values(array_unique($candidates)) as $candidate) {\n if ($this->readRelative($root, $candidate) !== null) {\n return $candidate;\n }\n }\n\n // Dernier recours sûr : retrouver un fichier de même nom sous\n // app/Controllers. On n'accepte le résultat que s'il est unique.\n $basename = basename($normalized) . '.php';\n $controllersRoot = $root . DIRECTORY_SEPARATOR . 'app/Controllers';\n $matches = [];\n if (is_dir($controllersRoot)) {\n $it = new \\RecursiveIteratorIterator(new \\RecursiveDirectoryIterator($controllersRoot, \\FilesystemIterator::SKIP_DOTS));\n foreach ($it as $file) {\n if (!$file->isFile() || $file->getFilename() !== $basename) {\n continue;\n }\n $relative = $this->relative($file->getPathname());\n if ($this->allowed($relative)) {\n $matches[] = $relative;\n }\n }\n }\n\n return count($matches) === 1 ? $matches[0] : 'app/Controllers/' . $normalized . '.php';\n }\n\n private function extractMethodBlock(string $content, string $method): string\n {\n $pattern = '/(?:public|protected|private)\\\\s+function\\\\s+' . preg_quote($method, '/') . '\\\\s*\\\\([^)]*\\\\)\\\\s*(?::\\\\s*[^\\\\{]+)?\\\\{/i';\n if (!preg_match($pattern, $content, $match, PREG_OFFSET_CAPTURE)) {\n return '';\n }\n\n $start = $match[0][1];\n $brace = strpos($content, '{', $start);\n if ($brace === false) {\n return '';\n }\n\n $depth = 0;\n $length = strlen($content);\n for ($i = $brace; $i < $length; $i++) {\n if ($content[$i] === '{') {\n $depth++;\n } elseif ($content[$i] === '}') {\n $depth--;\n if ($depth === 0) {\n return substr($content, $start, $i - $start + 1);\n }\n }\n }\n\n return AiTextEncodingService::slice($content, $start, min(self::MAX_SNIPPET * 2, $length - $start));\n }\n\n private function extractViews(string $content): array\n {\n $views = [];\n if (preg_match_all(\"/\\\\bview\\\\(\\\\s*['\\\"]([^'\\\"]+)['\\\"]/\", $content, $matches)) {\n $views = array_merge($views, $matches[1]);\n }\n if (preg_match_all(\"/\\\\breturn\\\\s+view\\\\(\\\\s*['\\\"]([^'\\\"]+)['\\\"]/\", $content, $matches)) {\n $views = array_merge($views, $matches[1]);\n }\n return array_values(array_unique(array_filter(array_map('trim', $views))));\n }\n\n private function extractAssetPaths(string $viewContent): array\n {\n $paths = [];\n\n if (preg_match_all(\"/(?:src|href)\\\\s*=\\\\s*['\\\"](?:\n if (preg_match_all(\"~['\\\"]/?assets/(?:js|css)/[^'\\\"?]+\\\\.(?:js|css)~i\", $viewContent, $matches)) {\n foreach ($matches[0] as $raw) {\n $raw = trim($raw, \"'\\\"\");\n $paths[] = 'public/' . ltrim($raw, '/');\n }\n }\n\n return array_values(array_unique($paths));\n }\n\n private function findCssForView(string $root, string $viewContent): array\n {\n $classes = [];\n if (preg_match_all('/class\\\\s*=\\\\s*[\"\\']([^\"\\']+)[\"\\']/i', $viewContent, $matches)) {\n foreach ($matches[1] as $classList) {\n foreach (preg_split('/\\\\s+/', trim($classList)) ?: [] as $class) {\n // On ne recherche que les classes suffisamment spécifiques,\n // pas toutes les classes Bootstrap génériques.\n if (strlen($class) >= 6 && (str_starts_with($class, 'dev-') || str_starts_with($class, 'evolution-') || str_contains($class, 'prompt'))) {\n $classes[$class] = true;\n }\n }\n }\n }\n\n if (!$classes) {\n return [];\n }\n\n $result = [];\n $cssDir = $root . DIRECTORY_SEPARATOR . 'public/assets';\n if (!is_dir($cssDir)) {\n return [];\n }\n\n $it = new \\RecursiveIteratorIterator(new \\RecursiveDirectoryIterator($cssDir, \\FilesystemIterator::SKIP_DOTS));\n foreach ($it as $file) {\n if (!$file->isFile() || strtolower($file->getExtension()) !== 'css' || $file->getSize() > self::MAX_FILE_SIZE) {\n continue;\n }\n $content = @file_get_contents($file->getPathname());\n if (!is_string($content)) {\n continue;\n }\n foreach (array_keys($classes) as $class) {\n if (str_contains($content, '.' . $class)) {\n $result[$this->relative($file->getPathname())] = $content;\n break;\n }\n }\n }\n\n return $result;\n }\n\n /**\n * Recherche déterministe des libellés significatifs présents dans la demande.\n * Les mots génériques sont ignorés : le but est de retrouver le texte réellement\n * visible dans une vue, pas de deviner un nom de fichier.\n */\n private function literalMatches(string $root, string $request): array\n {\n $needles = [];\n if (preg_match_all('/[\"“”«»\\']([^\"“”«»\\']{3,80})[\"“”«»\\']/u', $request, $m)) {\n foreach ($m[1] as $value) $needles[] = trim($value);\n }\n\n // Ajouter les groupes de mots avec majuscule, très fréquents pour les titres\n // et libellés d'interface : Administration, Mises à jour, Évolution IA, etc.\n if (preg_match_all('/\\b[\\p{Lu}][\\p{L}\\p{N}À-ÿ_-]*(?:\\s+(?:à|de|des|du|la|le|les|IA|[\\p{Lu}][\\p{L}\\p{N}À-ÿ_-]*)){0,3}\\b/u', $request, $m)) {\n foreach ($m[0] as $value) $needles[] = trim($value);\n }\n\n $stop = ['Je','Le','La','Les','Un','Une','Il','Elle','Pour','Dans','Sur','Page','Titre','Icon','Icône','Proposition'];\n $needles = array_values(array_unique(array_filter($needles, static fn(string $v): bool => mb_strlen($v) >= 4 && !in_array($v, $stop, true))));\n usort($needles, static fn(string $a, string $b): int => mb_strlen($b) <=> mb_strlen($a));\n $needles = array_slice($needles, 0, 8);\n if (!$needles) return [];\n\n $scored = [];\n foreach ($this->roots as $allowedRoot) {\n $dir = $root . DIRECTORY_SEPARATOR . $allowedRoot;\n if (!is_dir($dir)) continue;\n $it = new \\RecursiveIteratorIterator(new \\RecursiveDirectoryIterator($dir, \\FilesystemIterator::SKIP_DOTS));\n foreach ($it as $file) {\n if (!$file->isFile() || $file->getSize() > self::MAX_FILE_SIZE) continue;\n if (!in_array(strtolower($file->getExtension()), $this->extensions, true)) continue;\n $path = $this->relative($file->getPathname());\n if (!$this->allowed($path)) continue;\n $content = @file_get_contents($file->getPathname());\n if (!is_string($content)) continue;\n $score = 0;\n foreach ($needles as $needle) {\n $count = substr_count(mb_strtolower($content), mb_strtolower($needle));\n if ($count) $score += 20 + min($count, 5) * 3 + min(mb_strlen($needle), 30);\n }\n if ($score) $scored[$path] = ['score'=>$score, 'content'=>$content];\n }\n }\n uasort($scored, static fn(array $a, array $b): int => $b['score'] <=> $a['score']);\n $out=[];\n foreach (array_slice($scored, 0, 4, true) as $path=>$row) $out[$path]=$row['content'];\n return $out;\n }\n\n /** Suit sans écriture les références explicites découvertes dans une vue. */\n private function followFileReferences(string $root, string $path, string $content, array &$selected, array &$contents, array &$forcedSnippets, array &$chain): void\n {\n if (!str_starts_with($path, 'app/Views/')) return;\n $views = $this->extractViews($content);\n if (preg_match_all('/\\$this->(?:extend|include)\\(\\s*[\\'\\\"]([^\\'\\\"]+)[\\'\\\"]\\s*\\)/', $content, $m)) {\n $views = array_merge($views, $m[1]);\n }\n foreach (array_values(array_unique($views)) as $view) {\n if (str_contains($view, '$') || str_contains($view, '{')) continue;\n $viewPath='app/Views/'.trim($view,'/').'.php';\n $viewContent=$this->readRelative($root,$viewPath);\n if ($viewContent===null) continue;\n $this->addFile($selected,$contents,$viewPath,$viewContent);\n $forcedSnippets[$viewPath]=$viewContent;\n $chain[]='Référence de vue suivie automatiquement -> '.$viewPath;\n }\n foreach ($this->extractAssetPaths($content) as $assetPath) {\n $assetContent=$this->readRelative($root,$assetPath);\n if ($assetContent===null) continue;\n $this->addFile($selected,$contents,$assetPath,$assetContent);\n $chain[]='Asset suivi automatiquement -> '.$assetPath;\n }\n }\n\n private function scoreProjectFiles(string $root, array $terms): array\n {\n $scores = [];\n\n foreach ($this->roots as $allowedRoot) {\n $dir = $root . DIRECTORY_SEPARATOR . $allowedRoot;\n if (!is_dir($dir)) {\n continue;\n }\n\n $it = new \\RecursiveIteratorIterator(new \\RecursiveDirectoryIterator($dir, \\FilesystemIterator::SKIP_DOTS));\n foreach ($it as $file) {\n if (!$file->isFile() || $file->getSize() > self::MAX_FILE_SIZE) {\n continue;\n }\n $ext = strtolower($file->getExtension());\n if (!in_array($ext, $this->extensions, true)) {\n continue;\n }\n\n $path = $this->relative($file->getPathname());\n if (!$this->allowed($path)) {\n continue;\n }\n\n $content = @file_get_contents($file->getPathname());\n if (!is_string($content)) {\n continue;\n }\n\n $haystack = mb_strtolower($path . \"\\n\" . $content);\n $asciiHaystack = $this->ascii($haystack);\n $score = 0;\n foreach ($terms as $term) {\n $count = substr_count($haystack, $term);\n if ($count > 0) {\n $score += min($count, 8);\n }\n $asciiTerm = $this->ascii($term);\n if ($asciiTerm !== '' && $asciiTerm !== $term && str_contains($asciiHaystack, $asciiTerm)) {\n $score += 2;\n }\n if (str_contains(mb_strtolower($path), $term)) {\n $score += 8;\n }\n }\n if ($score > 0) {\n $scores[$path] = $score;\n }\n }\n }\n\n arsort($scores);\n return $scores;\n }\n\n /**\n * Les fichiers déterministes (contrôleur, vue, layout) doivent être transmis\n * intégralement lorsqu'ils restent de taille raisonnable. Un simple snippet de\n * 30 Ko pouvait auparavant couper une méthode au milieu et empêcher l'IA de\n * produire un diff fiable.\n */\n private function targetContent(string $content): string\n {\n if (strlen($content) <= self::MAX_TARGET_CONTENT) {\n return $content;\n }\n\n // Pour les fichiers exceptionnellement volumineux, conserver des blocs\n // clairement signalés plutôt que de prétendre avoir fourni le fichier entier.\n $half = intdiv(self::MAX_TARGET_CONTENT, 2);\n return AiTextEncodingService::slice($content, 0, $half)\n . \"\\n\\n/* === evolution: partie centrale omise (fichier > limite ciblée) === */\\n\\n\"\n . AiTextEncodingService::slice($content, -$half);\n }\n\n private function addFile(array &$selected, array &$contents, string $path, string $content): void\n {\n if (!$this->allowed($path) || isset($contents[$path])) {\n return;\n }\n $selected[] = $path;\n $contents[$path] = $content;\n }\n\n private function readRelative(string $root, string $path): ?string\n {\n if (!$this->allowed($path)) {\n return null;\n }\n\n $full = $root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $path);\n if (!is_file($full) || filesize($full) > self::MAX_FILE_SIZE) {\n return null;\n }\n\n $realRoot = realpath($root);\n $realFull = realpath($full);\n if ($realRoot === false || $realFull === false || !str_starts_with($realFull, $realRoot . DIRECTORY_SEPARATOR)) {\n return null;\n }\n\n $content = @file_get_contents($realFull);\n return is_string($content) ? $content : null;\n }\n\n private function terms(string $request): array\n {\n $text = mb_strtolower(strip_tags($request));\n $words = preg_split('/[^\\\\p{L}\\\\p{N}_-]+/u', $text, -1, PREG_SPLIT_NO_EMPTY) ?: [];\n $stop = [\n 'avec','dans','pour','mais','plus','cette','cela','comme','faire','mettre','mise','jour','actuel','actuelle',\n 'être','avoir','tout','tous','toute','toutes','une','des','les','que','qui','sur','mon','ma','mes','du','de','la',\n 'le','un','et','ou','est','sont','je','tu','il','elle','nous','vous','ils','elles','trop','petit','petite','page',\n 'formulaire','analyse','analyser','propose','proposer','amélioration','amelioration','retrouve','retrouver','même','meme'\n ];\n $words = array_values(array_filter(\n $words,\n static fn(string $word): bool => mb_strlen($word) >= 3 && !in_array($word, $stop, true)\n ));\n\n // Ajouter les variantes ASCII facilite evolution <-> évolution.\n $terms = [];\n foreach ($words as $word) {\n $terms[] = $word;\n $ascii = $this->ascii($word);\n if ($ascii !== '' && $ascii !== $word) {\n $terms[] = $ascii;\n }\n }\n\n return array_slice(array_values(array_unique($terms)), 0, 12);\n }\n\n private function snippet(string $content, array $terms): string\n {\n if (strlen($content) <= self::MAX_SNIPPET) {\n return $content;\n }\n\n $lower = mb_strtolower($content);\n $position = false;\n foreach ($terms as $term) {\n $pos = mb_strpos($lower, $term);\n if ($pos !== false) {\n $position = $pos;\n break;\n }\n }\n\n if ($position === false) {\n return AiTextEncodingService::slice($content, 0, self::MAX_SNIPPET);\n }\n\n $prefix = mb_substr($content, 0, max(0, $position - 800));\n $bytePosition = strlen($prefix);\n return AiTextEncodingService::slice($content, max(0, $bytePosition), self::MAX_SNIPPET);\n }\n\n private function ascii(string $value): string\n {\n $converted = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value);\n return mb_strtolower(is_string($converted) ? $converted : $value);\n }\n\n private function relative(string $path): string\n {\n $root = rtrim(str_replace('\\\\', '/', $this->projectRoot), '/');\n return ltrim(substr(str_replace('\\\\', '/', $path), strlen($root)), '/');\n }\n\n private function allowed(string $path): bool\n {\n $path = ltrim(str_replace('\\\\', '/', $path), '/');\n if ($path === '' || str_contains($path, '..')) {\n return false;\n }\n\n foreach (['.env', '.git/', '.ssh/', 'writable/', 'public/uploads/'] as $blocked) {\n if (str_starts_with($path, $blocked) || str_contains($path, '/' . $blocked)) {\n return false;\n }\n }\n\n $inReadableRoot = str_starts_with($path, 'app/') || str_starts_with($path, 'public/') || str_starts_with($path, 'scripts/') || str_starts_with($path, 'install/');\n $rootReadable = in_array($path, ['composer.json','composer.lock','spark','.htaccess','env.example','env.ovh.example','README-RESTORE.md'], true);\n if (!$inReadableRoot && !$rootReadable) return false;\n if ($rootReadable) return true;\n $base = strtolower(basename($path));\n if ($base === '.htaccess') return true;\n $ext = strtolower((string) pathinfo($path, PATHINFO_EXTENSION));\n return in_array($ext, ['php','js','css','json','md','txt','sql','xml','yml','yaml','ini','lock','example','dist','sh','svg'], true);\n }\n}\n\n--- END PINNED app/Services/DevelopmentCodeExplorerService.php | COMPLET ---\n\nREGLE FINALE : ne demande jamais de relire un fichier marque COMPLET ci-dessus. Produis maintenant files[]/database ou indique uniquement une dependance concrete absente.\nCONTEXTE TECHNIQUE evolution (généré automatiquement)\nVersion installée : 3.10.5\nFramework : CodeIgniter 4\nUI : Bootstrap 5 + Bootstrap Icons\nBase : MySQL/MariaDB\nAPI IA : OpenAI Responses API avec streaming\nHébergement : environnement web PHP, déploiement compatible OVH mutualisé\nMise à jour : gestionnaire ZIP versionné avec migrations CodeIgniter, sauvegarde préalable et historique app_versions.\nFichiers/données protégés : .env, .env.ovh, .git, .ssh, writable, public/uploads.\nTables actuellement visibles : dev_ai_access, dev_ai_access_logs, dev_ai_access_requests, dev_ai_usage_logs, dev_conversations, dev_logs, dev_messages, dev_project_knowledge, dev_proposal_database, dev_proposal_files, dev_proposal_git_states, dev_proposals, migrations, password_resets, settings, users.\nImportant : l’assistant utilisateur /app est distinct de ce Development Engine d’administration. Ne pas mélanger leurs conversations ni leurs prompts.", "input": "PHASE FINALISATION evolution. INTENTION RACINE VERROUILLEE sha256=a0c379af102d35469837e7c0099172efdb3070177a04de642cdf00d3ee4096d6. Le contexte technique est final et déjà exploré. Réponds UNIQUEMENT avec un objet JSON valide, sans markdown. Schéma autorisé: {\"status\":\"proposal|no_change|needs_context\",\"title\":string,\"summary\":string,\"files\":[{\"path\":string,\"action\":\"modify|create\",\"content\":string,\"edits\":[{\"symbol\":\"method:nom\",\"replace\":string}|{\"search\":string,\"replace\":string}|{\"start\":string,\"end\":string,\"replace\":string}]}],\"database\":null|{\"mode\":\"migration\",\"summary\":string,\"migration_path\":string,\"content\":string,\"risk_level\":\"low|medium|high\",\"reversible\":bool},\"needs_context\":[{\"type\":\"path|symbol|method|route|table|schema|migration|backup|install|rollback|search|service\",\"value\":string}]}. Pour action=modify, N ENVOIE PAS le fichier complet : utilise exclusivement edits[] avec les plus petits remplacements exacts possibles et omets content. Chaque search doit être une chaîne exacte présente UNE SEULE FOIS dans le fichier réel épinglé ; replace contient sa version finale. Pour une méthode PHP, préfère TOUJOURS symbol=\"method:Nom\"/replace. Pour un autre bloc volumineux, préfère start/end/replace : start et end sont deux ancres exactes, courtes et uniques du fichier, et replace contient la zone finale complète, ancres comprises. N’envoie jamais un search gigantesque simplement pour remplacer un gros bloc. evolution reconstruit localement le fichier complet, puis effectue ses validations, son diff et son SHA-256 sans faire payer sa réémission au modèle. Pour action=create seulement, utilise content avec le contenu complet et edits=[]. Tu ne peux demander une lecture tardive QUE si un composant indispensable manque réellement du contexte final. Si tu identifies des fichiers ou tables manquants, retourne obligatoirement status=needs_context avec needs_context structuré : n'écris jamais une section prose 'Étape requise' ou 'Exploration requise' destinée à l'administrateur. Dans ce cas, status=needs_context, files=[], database=null et needs_context doit contenir uniquement des cibles techniques concrètes et structurées. Pour un fichier précis du PROJET CIBLE, utilise type=path avec son chemin exact. Les blocs CROSS PROJECT SOURCE sont déjà lus intégralement et sont strictement en lecture seule : ne redemande pas leur path comme s'il appartenait au projet cible et ne les place jamais dans files[]. Pour une structure de table utilise schema, pour ses migrations migration, et pour les règles du cycle d’exécution backup/install/rollback. Pour un Model CodeIgniter, demande son symbole : evolution fournit automatiquement le modèle complet puis sa table déclarée, son schéma réel, ses index/contraintes et ses migrations. Pour une recherche littérale indispensable dans le code, utilise search avec le terme exact. Pour les services liés aux fichiers/uploads/media, utilise service avec file-management. Si tu émets malgré tout dans summary des lignes structurées comme path:, schema:, migration:, symbol:, search: ou service:, evolution les exécutera comme plan interne et te rappellera automatiquement ; elles ne sont jamais destinées à l'administrateur. Tout bloc \"BEGIN DATABASE SCHEMA
| REAL DATABASE\" est une lecture réelle et épinglée de la base connectée : tu ne peux pas déclarer ce schéma absent s'il est présent. N’annonce jamais simplement une prochaine étape. BUDGET SORTIE STRICT : minimise le JSON. summary bref. Pour un fichier existant, ne recopie aucune portion inchangée hors des petites ancres nécessaires. Utilise symbol/replace pour les méthodes PHP, sinon start/end pour éviter les gros search. Si une modification est nécessaire, status DOIT être proposal et files[] et/ou database DOIT être non vide. Pour chaque fichier existant modifié, retourne uniquement des edits exacts minimaux ; ne réémets jamais son contenu complet. Pour un nouveau fichier seulement, fournis content complet. Si réellement aucune modification n’est nécessaire, utilise status=no_change, files=[], database=null et justifie précisément dans summary. Maximum 7 fichiers migration comprise. Chemins autorisés: app/ et public/assets/. Ne touche jamais .env, writable, public/uploads, .git ou vendor. REGLE CANONIQUE MIGRATION BDD : une migration est représentée EXCLUSIVEMENT dans database. Si database != null, database.migration_path contient son chemin et database.content son contenu PHP COMPLET. Le même migration_path NE DOIT JAMAIS apparaître dans files[]. files[] contient uniquement les autres fichiers applicatifs. REGLE CANONIQUE FILES : un même path ne doit apparaître qu’une seule fois dans files[]. Si plusieurs zones d’un fichier doivent changer, regroupe tous leurs edits[] dans cette unique entrée. La migration compte néanmoins dans la limite globale de 7 fichiers. Si une migration est nécessaire, utilise CodeIgniter 4 Forge/Query Builder, namespace App\\Database\\Migrations, extends Migration, up() et down(), sans SQL brut. Nom conseillé: app/Database/Migrations/2026-10-10-232904_NomDescriptif.php. DIAGNOSTIC evolution: Contexte garanti : 1 fichier(s) réellement injecté(s) : app/Services/DevelopmentCodeExplorerService.php (83944 octets). DEMANDE RACINE IMMUABLE: DEMANDE RACINE (IMMUTABLE) :\nModifie le titre \"Nexum AI - Développement\" en \"Matrice de développement\" dans la page actuelle IMPORTANT: les tâches d exploration (ZIP, rotation, route, vue, modèle, validation, etc.) sont seulement des vérifications intermédiaires et ne remplacent jamais cette demande racine.\nCONTROLE PAYLOAD EFFECTIF : le bloc CONTEXT PAYLOAD INSPECTOR placé en tête du contexte décrit exactement les fichiers et schémas présents dans CE payload. Tu dois le considérer comme autoritatif.", "stream": true, "prompt_cache_key": "evolution-development-de330c965db3e258acdb73abb0071c940c1e5083", "reasoning": { "effort": "low" }, "max_output_tokens": 24000, "text": { "format": { "type": "json_object" }, "verbosity": "low" } } ======================================================================== TAILLES ======================================================================== model : 13 caractères instructions : 86 476 caractères input : 5 852 caractères prompt_cache_key : 62 caractères reasoning : 16 caractères text : 51 caractères payload_total : 95 093 caractères