agentic-api 2.0.646 → 2.0.885

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/src/agents/prompts.d.ts +2 -3
  2. package/dist/src/agents/prompts.js +21 -118
  3. package/dist/src/agents/reducer.loaders.d.ts +103 -1
  4. package/dist/src/agents/reducer.loaders.js +164 -2
  5. package/dist/src/agents/reducer.types.d.ts +34 -3
  6. package/dist/src/agents/simulator.d.ts +32 -2
  7. package/dist/src/agents/simulator.executor.d.ts +15 -5
  8. package/dist/src/agents/simulator.executor.js +134 -67
  9. package/dist/src/agents/simulator.js +251 -8
  10. package/dist/src/agents/simulator.prompts.d.ts +55 -10
  11. package/dist/src/agents/simulator.prompts.js +305 -61
  12. package/dist/src/agents/simulator.types.d.ts +62 -1
  13. package/dist/src/agents/simulator.types.js +5 -0
  14. package/dist/src/agents/subagent.d.ts +128 -0
  15. package/dist/src/agents/subagent.js +231 -0
  16. package/dist/src/agents/worker.executor.d.ts +48 -0
  17. package/dist/src/agents/worker.executor.js +152 -0
  18. package/dist/src/execute/helpers.d.ts +3 -0
  19. package/dist/src/execute/helpers.js +222 -16
  20. package/dist/src/execute/responses.js +81 -55
  21. package/dist/src/execute/shared.d.ts +5 -0
  22. package/dist/src/execute/shared.js +27 -0
  23. package/dist/src/index.d.ts +2 -1
  24. package/dist/src/index.js +3 -1
  25. package/dist/src/llm/openai.js +8 -1
  26. package/dist/src/llm/pricing.js +2 -0
  27. package/dist/src/llm/xai.js +11 -6
  28. package/dist/src/prompts.d.ts +14 -0
  29. package/dist/src/prompts.js +41 -1
  30. package/dist/src/rag/rag.manager.d.ts +18 -3
  31. package/dist/src/rag/rag.manager.js +114 -12
  32. package/dist/src/rag/types.d.ts +3 -1
  33. package/dist/src/rules/git/git.e2e.helper.js +51 -4
  34. package/dist/src/rules/git/git.health.js +89 -56
  35. package/dist/src/rules/git/index.d.ts +2 -2
  36. package/dist/src/rules/git/index.js +22 -5
  37. package/dist/src/rules/git/repo.d.ts +64 -6
  38. package/dist/src/rules/git/repo.js +572 -141
  39. package/dist/src/rules/git/repo.pr.d.ts +11 -18
  40. package/dist/src/rules/git/repo.pr.js +82 -94
  41. package/dist/src/rules/git/repo.tools.d.ts +5 -0
  42. package/dist/src/rules/git/repo.tools.js +6 -1
  43. package/dist/src/rules/types.d.ts +0 -2
  44. package/dist/src/rules/utils.matter.js +1 -5
  45. package/dist/src/scrapper.d.ts +138 -25
  46. package/dist/src/scrapper.js +538 -160
  47. package/dist/src/stategraph/stategraph.d.ts +6 -2
  48. package/dist/src/stategraph/stategraph.js +21 -6
  49. package/dist/src/stategraph/types.d.ts +14 -6
  50. package/dist/src/types.d.ts +22 -0
  51. package/dist/src/utils.d.ts +24 -0
  52. package/dist/src/utils.js +84 -86
  53. package/package.json +3 -2
  54. package/dist/src/agents/semantic.d.ts +0 -4
  55. package/dist/src/agents/semantic.js +0 -19
  56. package/dist/src/execute/legacy.d.ts +0 -46
  57. package/dist/src/execute/legacy.js +0 -460
  58. package/dist/src/pricing.llm.d.ts +0 -5
  59. package/dist/src/pricing.llm.js +0 -14
@@ -35,9 +35,14 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.gitIDRegistryExists = gitIDRegistryExists;
37
37
  exports.gitGenerateNextID = gitGenerateNextID;
38
+ exports.gitGenerateNextPRNumber = gitGenerateNextPRNumber;
39
+ exports.gitEnsureMinimumPRCounter = gitEnsureMinimumPRCounter;
40
+ exports.gitAllocateNextPRNumber = gitAllocateNextPRNumber;
38
41
  exports.gitReloadIDRegistry = gitReloadIDRegistry;
39
42
  exports.gitRegisterExistingID = gitRegisterExistingID;
40
43
  exports.gitIDRegistryRename = gitIDRegistryRename;
44
+ exports.gitGetMatterCache = gitGetMatterCache;
45
+ exports.gitSetMatterCache = gitSetMatterCache;
41
46
  exports.gitEnsureMatterID = gitEnsureMatterID;
42
47
  exports.gitFileStrictMatter = gitFileStrictMatter;
43
48
  exports.gitEnsureRepositoryConfiguration = gitEnsureRepositoryConfiguration;
@@ -48,8 +53,7 @@ exports.gitShowConfiguration = gitShowConfiguration;
48
53
  exports.gitCheckConfiguration = gitCheckConfiguration;
49
54
  exports.gitCreateOrEditFile = gitCreateOrEditFile;
50
55
  exports.gitEditFile = gitEditFile;
51
- exports.gitRenameFile_git = gitRenameFile_git;
52
- exports.gitRenameFile_fs = gitRenameFile_fs;
56
+ exports.gitUpdateMatter = gitUpdateMatter;
53
57
  exports.gitRenameFile = gitRenameFile;
54
58
  exports.gitDeleteFile = gitDeleteFile;
55
59
  exports.gitGetBranchHealth = gitGetBranchHealth;
@@ -59,8 +63,9 @@ const errors_1 = require("../errors");
59
63
  const path_1 = require("path");
60
64
  const fs = __importStar(require("fs/promises"));
61
65
  const repo_tools_1 = require("./repo.tools");
62
- const repo_pr_1 = require("./repo.pr");
63
66
  const utils_matter_1 = require("../utils.matter");
67
+ const utils_slug_1 = require("../utils.slug");
68
+ const utils_1 = require("../../utils");
64
69
  /**
65
70
  * Service singleton pour gérer le registre d'IDs en mémoire
66
71
  */
@@ -81,6 +86,110 @@ class IDRegistryService {
81
86
  this.repoPath = '';
82
87
  this.isDirty = false;
83
88
  }
89
+ createEmptyRegistry() {
90
+ return {
91
+ schemaVersion: 2,
92
+ last: 980,
93
+ lastPR: 60,
94
+ used: [],
95
+ updated: new Date().toISOString(),
96
+ matters: {}
97
+ };
98
+ }
99
+ parseMatterKey(key) {
100
+ const separatorIndex = key.indexOf(':');
101
+ if (separatorIndex < 0) {
102
+ return null;
103
+ }
104
+ return {
105
+ branch: key.slice(0, separatorIndex),
106
+ suffix: key.slice(separatorIndex + 1)
107
+ };
108
+ }
109
+ createMatterKey(branch, id) {
110
+ return `${branch}:${id}`;
111
+ }
112
+ normalizeMatterCache(branch, keySuffix, rawMatter) {
113
+ if (!rawMatter || typeof rawMatter !== 'object') {
114
+ return null;
115
+ }
116
+ const id = typeof rawMatter.id === 'number' ? rawMatter.id : undefined;
117
+ if (!id || !Number.isFinite(id)) {
118
+ return null;
119
+ }
120
+ const file = typeof rawMatter.file === 'string' && rawMatter.file.trim() !== ''
121
+ ? rawMatter.file
122
+ : keySuffix;
123
+ const normalized = {
124
+ id,
125
+ file,
126
+ title: rawMatter.title,
127
+ service: rawMatter.service,
128
+ updated: typeof rawMatter.updated === 'string' ? rawMatter.updated : new Date().toISOString()
129
+ };
130
+ return {
131
+ cacheKey: this.createMatterKey(branch, id),
132
+ cacheValue: normalized
133
+ };
134
+ }
135
+ mergeMatterCache(existing, incoming) {
136
+ if (!existing) {
137
+ return incoming;
138
+ }
139
+ const existingUpdated = Date.parse(existing.updated || '');
140
+ const incomingUpdated = Date.parse(incoming.updated || '');
141
+ const preferIncoming = !Number.isNaN(incomingUpdated) && (Number.isNaN(existingUpdated) || incomingUpdated >= existingUpdated);
142
+ const primary = preferIncoming ? incoming : existing;
143
+ const secondary = preferIncoming ? existing : incoming;
144
+ return {
145
+ id: primary.id ?? secondary.id,
146
+ file: primary.file || secondary.file,
147
+ title: primary.title ?? secondary.title,
148
+ service: primary.service ?? secondary.service,
149
+ updated: primary.updated || secondary.updated
150
+ };
151
+ }
152
+ migrateRegistry(data) {
153
+ const fallback = this.createEmptyRegistry();
154
+ const migrated = {
155
+ schemaVersion: 2,
156
+ last: Number.isInteger(data?.last) ? data.last : fallback.last,
157
+ lastPR: Number.isInteger(data?.lastPR) ? data.lastPR : fallback.lastPR,
158
+ used: Array.isArray(data?.used) ? data.used.filter((value) => typeof value === 'number') : [],
159
+ updated: typeof data?.updated === 'string' ? data.updated : fallback.updated,
160
+ matters: {}
161
+ };
162
+ let migrationApplied = data?.schemaVersion !== 2;
163
+ const rawMatters = data?.matters && typeof data.matters === 'object' ? data.matters : {};
164
+ for (const [rawKey, rawMatter] of Object.entries(rawMatters)) {
165
+ const parsed = this.parseMatterKey(rawKey);
166
+ if (!parsed) {
167
+ migrationApplied = true;
168
+ continue;
169
+ }
170
+ const normalized = this.normalizeMatterCache(parsed.branch, parsed.suffix, rawMatter);
171
+ if (!normalized) {
172
+ migrationApplied = true;
173
+ continue;
174
+ }
175
+ if (normalized.cacheKey !== rawKey) {
176
+ migrationApplied = true;
177
+ }
178
+ migrated.matters[normalized.cacheKey] = this.mergeMatterCache(migrated.matters[normalized.cacheKey], normalized.cacheValue);
179
+ }
180
+ const used = new Set(migrated.used);
181
+ for (const matter of Object.values(migrated.matters)) {
182
+ if (typeof matter.id === 'number') {
183
+ used.add(matter.id);
184
+ }
185
+ }
186
+ migrated.used = Array.from(used).sort((left, right) => left - right);
187
+ migrated.last = Math.max(migrated.last, migrated.used.length ? migrated.used[migrated.used.length - 1] : fallback.last);
188
+ if (migrationApplied) {
189
+ this.isDirty = true;
190
+ }
191
+ return migrated;
192
+ }
84
193
  /**
85
194
  * Initialise le service avec le chemin du repository
86
195
  */
@@ -93,14 +202,12 @@ class IDRegistryService {
93
202
  // Si le registre existe déjà, le recharger depuis le nouveau chemin
94
203
  if (this.registry) {
95
204
  this.registry = this.loadFromDisk();
96
- this.isDirty = false;
97
205
  return;
98
206
  }
99
207
  }
100
208
  // Si le registre n'existe pas encore, le créer
101
209
  if (!this.registry) {
102
210
  this.registry = this.loadFromDisk();
103
- this.isDirty = false;
104
211
  }
105
212
  }
106
213
  /**
@@ -112,41 +219,29 @@ class IDRegistryService {
112
219
  throw new Error('IDRegistryService not initialized. Call init() first.');
113
220
  }
114
221
  this.registry = this.loadFromDisk();
115
- this.isDirty = false;
116
222
  }
117
223
  /**
118
224
  * Charge le registre depuis le disque
119
225
  */
120
226
  loadFromDisk() {
121
227
  const registryPath = (0, path_1.join)(this.repoPath, '.git', 'with-ids.json');
228
+ this.isDirty = false;
122
229
  if (!(0, fs_1.existsSync)(registryPath)) {
123
- return {
124
- last: 980,
125
- used: [],
126
- updated: new Date().toISOString(),
127
- matters: {}
128
- };
230
+ return this.createEmptyRegistry();
129
231
  }
130
232
  try {
131
233
  const data = JSON.parse((0, fs_1.readFileSync)(registryPath, 'utf8'));
132
- // Migration : ajouter matters si absent (compatibilité anciens registres)
133
- if (!data.matters) {
134
- data.matters = {};
135
- }
136
- return data;
234
+ return this.migrateRegistry(data);
137
235
  }
138
236
  catch (error) {
139
237
  console.warn('⚠️ Registre d\'IDs corrompu, création d\'un nouveau');
140
- return {
141
- last: 980,
142
- used: [],
143
- updated: new Date().toISOString(),
144
- matters: {}
145
- };
238
+ this.isDirty = false;
239
+ return this.createEmptyRegistry();
146
240
  }
147
241
  }
148
242
  /**
149
- * Sauvegarde le registre sur le disque (seulement si modifié)
243
+ * Sauvegarde le registre sur le disque (seulement si modifié).
244
+ * L'échec est fatal: on propage une erreur pour éviter un état incohérent.
150
245
  */
151
246
  save() {
152
247
  if (!this.registry || !this.isDirty) {
@@ -165,8 +260,7 @@ class IDRegistryService {
165
260
  this.isDirty = false;
166
261
  }
167
262
  catch (error) {
168
- console.warn(`⚠️ Failed to save ID registry:`, error);
169
- // Ne pas propager l'erreur, le registre est une optimisation
263
+ throw new errors_1.GitOperationError(`Failed to save ID registry: ${error}`, 'id_registry_save');
170
264
  }
171
265
  }
172
266
  /**
@@ -191,6 +285,35 @@ class IDRegistryService {
191
285
  this.isDirty = true;
192
286
  return newID;
193
287
  }
288
+ /**
289
+ * Génère le prochain numéro de PR (séquence indépendante des IDs documents)
290
+ */
291
+ generateNextPRNumber(minValue = 60) {
292
+ if (!this.registry) {
293
+ throw new Error('IDRegistryService not initialized. Call init() first.');
294
+ }
295
+ const baseline = Math.max(Number.isInteger(this.registry.lastPR) ? this.registry.lastPR : minValue, minValue);
296
+ const newPR = baseline + 1;
297
+ this.registry.lastPR = newPR;
298
+ this.isDirty = true;
299
+ return newPR;
300
+ }
301
+ /**
302
+ * Force le compteur PR à une borne minimale (sans allouer de numéro).
303
+ * Utile pour réaligner le registre avec des branches déjà présentes.
304
+ */
305
+ ensureMinimumPRCounter(minCounter) {
306
+ if (!this.registry) {
307
+ throw new Error('IDRegistryService not initialized. Call init() first.');
308
+ }
309
+ const normalized = Number.isInteger(minCounter) ? minCounter : 60;
310
+ const current = Number.isInteger(this.registry.lastPR) ? this.registry.lastPR : 60;
311
+ const next = Math.max(current, normalized, 60);
312
+ if (next !== this.registry.lastPR) {
313
+ this.registry.lastPR = next;
314
+ this.isDirty = true;
315
+ }
316
+ }
194
317
  /**
195
318
  * Obtient l'ID d'un fichier (peu importe la branche)
196
319
  *
@@ -204,14 +327,23 @@ class IDRegistryService {
204
327
  if (!this.registry) {
205
328
  return undefined;
206
329
  }
207
- // Chercher le fichier dans toutes les branches
208
- for (const [cacheKey, matter] of Object.entries(this.registry.matters)) {
209
- const [, cachedFile] = cacheKey.split(':');
210
- if (cachedFile === file) {
211
- return matter.id;
330
+ // Chercher le fichier dans toutes les branches.
331
+ // Si plusieurs IDs existent (état incohérent), retourner un ID canonique déterministe
332
+ // pour éviter les oscillations entre redémarrages.
333
+ const ids = new Set();
334
+ for (const matter of Object.values(this.registry.matters)) {
335
+ if (matter.file === file && typeof matter.id === 'number') {
336
+ ids.add(matter.id);
212
337
  }
213
338
  }
214
- return undefined;
339
+ if (ids.size === 0) {
340
+ return undefined;
341
+ }
342
+ if (ids.size === 1) {
343
+ return [...ids][0];
344
+ }
345
+ // Politique d'arbitrage: conserver le plus petit ID observé pour ce fichier.
346
+ return Math.min(...ids);
215
347
  }
216
348
  /**
217
349
  * Vérifie si un ID appartient déjà à un fichier spécifique
@@ -237,18 +369,14 @@ class IDRegistryService {
237
369
  * @param id L'ID à chercher
238
370
  * @returns Le nom du fichier qui possède cet ID, ou undefined
239
371
  */
240
- getFileByID(id) {
372
+ getFileByID(branch, id) {
373
+ return this.getMatterCache(branch, id)?.file;
374
+ }
375
+ getMatterCachesByID(id) {
241
376
  if (!this.registry) {
242
- return undefined;
377
+ return [];
243
378
  }
244
- // Chercher l'ID dans le cache
245
- for (const [cacheKey, matter] of Object.entries(this.registry.matters)) {
246
- if (matter.id === id) {
247
- const [, file] = cacheKey.split(':');
248
- return file;
249
- }
250
- }
251
- return undefined;
379
+ return Object.values(this.registry.matters).filter(matter => matter.id === id);
252
380
  }
253
381
  /**
254
382
  * Enregistre un ID existant
@@ -290,22 +418,24 @@ class IDRegistryService {
290
418
  * Récupère un matter depuis le cache
291
419
  * Note: Le champ 'updated' est filtré pour ne pas être exposé à l'extérieur
292
420
  */
293
- getMatterCache(branch, file) {
421
+ getMatterCache(branch, id) {
294
422
  if (!this.registry) {
295
423
  throw new Error('IDRegistryService not initialized. Call init() first.');
296
424
  }
297
- const cacheKey = `${branch}:${file}`;
298
- const cached = this.registry.matters[cacheKey];
299
- if (!cached) {
300
- return undefined;
425
+ const cacheKey = this.createMatterKey(branch, id);
426
+ return this.registry.matters[cacheKey];
427
+ }
428
+ getMatterCacheByFile(branch, file) {
429
+ if (!this.registry) {
430
+ throw new Error('IDRegistryService not initialized. Call init() first.');
301
431
  }
302
- // Retourner uniquement les champs publics (sans 'updated')
303
- return {
304
- id: cached.id,
305
- title: cached.title,
306
- service: cached.service,
307
- oldfile: cached.oldfile // FIXME (check if needed) ✅ Inclure oldfile temporaire si présent
308
- };
432
+ for (const [cacheKey, matter] of Object.entries(this.registry.matters)) {
433
+ const parsed = this.parseMatterKey(cacheKey);
434
+ if (parsed?.branch === branch && matter.file === file) {
435
+ return matter;
436
+ }
437
+ }
438
+ return undefined;
309
439
  }
310
440
  /**
311
441
  * Met à jour le cache d'un matter
@@ -314,12 +444,17 @@ class IDRegistryService {
314
444
  if (!this.registry) {
315
445
  throw new Error('IDRegistryService not initialized. Call init() first.');
316
446
  }
317
- const cacheKey = `${branch}:${file}`;
447
+ if (!matter.id || !Number.isFinite(matter.id)) {
448
+ this.deleteMatterCacheByFile(branch, file);
449
+ return;
450
+ }
451
+ this.deleteMatterCacheByFile(branch, file);
452
+ const cacheKey = this.createMatterKey(branch, matter.id);
318
453
  this.registry.matters[cacheKey] = {
319
454
  id: matter.id,
455
+ file,
320
456
  title: matter.title,
321
457
  service: matter.service,
322
- oldfile: matter.oldfile, // ✅ Inclure oldfile temporaire
323
458
  updated: new Date().toISOString()
324
459
  };
325
460
  this.isDirty = true;
@@ -327,14 +462,21 @@ class IDRegistryService {
327
462
  /**
328
463
  * Supprime une entrée du cache
329
464
  */
330
- deleteMatterCache(branch, file) {
465
+ deleteMatterCache(branch, id) {
331
466
  if (!this.registry) {
332
467
  throw new Error('IDRegistryService not initialized. Call init() first.');
333
468
  }
334
- const cacheKey = `${branch}:${file}`;
469
+ const cacheKey = this.createMatterKey(branch, id);
335
470
  delete this.registry.matters[cacheKey];
336
471
  this.isDirty = true;
337
472
  }
473
+ deleteMatterCacheByFile(branch, file) {
474
+ const cached = this.getMatterCacheByFile(branch, file);
475
+ if (!cached?.id) {
476
+ return;
477
+ }
478
+ this.deleteMatterCache(branch, cached.id);
479
+ }
338
480
  /**
339
481
  * Renomme une entrée dans le cache du registre
340
482
  *
@@ -353,27 +495,18 @@ class IDRegistryService {
353
495
  * idRegistryService.rename('old-name.md', 'new-name.md', 'rule-validation-1');
354
496
  * ```
355
497
  */
356
- rename(oldFile, newFile, branch) {
498
+ rename(id, newFile, branch) {
357
499
  if (!this.registry) {
358
500
  throw new Error('IDRegistryService not initialized. Call init() first.');
359
501
  }
360
- const oldKey = `${branch}:${oldFile}`;
361
- const newKey = `${branch}:${newFile}`;
362
- // Récupérer le cache existant
363
- const cached = this.registry.matters[oldKey];
502
+ const cacheKey = this.createMatterKey(branch, id);
503
+ const cached = this.registry.matters[cacheKey];
364
504
  if (!cached) {
365
- // Pas de cache pour l'ancien fichier, rien à renommer
366
505
  return;
367
506
  }
368
- // Supprimer l'ancienne clé
369
- delete this.registry.matters[oldKey];
370
- //
371
- // Créer la nouvelle clé avec les mêmes données
372
- // ⚠️ NE PAS inclure oldfile - c'est temporaire uniquement pour notification client
373
- this.registry.matters[newKey] = {
374
- id: cached.id,
375
- title: cached.title,
376
- service: cached.service,
507
+ this.registry.matters[cacheKey] = {
508
+ ...cached,
509
+ file: newFile,
377
510
  updated: new Date().toISOString()
378
511
  };
379
512
  this.isDirty = true;
@@ -389,6 +522,9 @@ const idRegistryService = IDRegistryService.get();
389
522
  * dans le repository. Le fichier `with-ids.json` est créé automatiquement lors du
390
523
  * premier appel à `gitGenerateNextID()` ou `gitEnsureMatterID()`.
391
524
  *
525
+ * @deprecated API utilitaire de compatibilité. Préférer la résolution via
526
+ * gitEnsureMatterID() / gitFileStrictMatter() sans dépendre d'un check préalable.
527
+ *
392
528
  * @param config Configuration Git optionnelle (utilise la config par défaut si non fournie)
393
529
  * @returns `true` si le fichier `.git/with-ids.json` existe, `false` sinon
394
530
  *
@@ -426,6 +562,60 @@ function gitGenerateNextID(config) {
426
562
  idRegistryService.save();
427
563
  return newID;
428
564
  }
565
+ /**
566
+ * Génère le prochain numéro de PR via le registre `.git/with-ids.json`.
567
+ * Cette séquence est indépendante des IDs documentaires.
568
+ *
569
+ * @param config Configuration Git optionnelle
570
+ * @param minValue Valeur plancher du compteur PR (60 par défaut)
571
+ * @returns Prochain numéro de PR
572
+ */
573
+ function gitGenerateNextPRNumber(config, minValue = 60) {
574
+ const gitConf = (0, repo_tools_1.gitLoad)(config);
575
+ idRegistryService.init(gitConf.repoPath);
576
+ const newPR = idRegistryService.generateNextPRNumber(minValue);
577
+ idRegistryService.save();
578
+ return newPR;
579
+ }
580
+ /**
581
+ * Réaligne le compteur PR du registre sur une borne minimale sans allouer de numéro.
582
+ *
583
+ * @param minCounter Valeur minimale à garantir pour `lastPR`
584
+ * @param config Configuration Git optionnelle
585
+ */
586
+ function gitEnsureMinimumPRCounter(minCounter, config) {
587
+ const gitConf = (0, repo_tools_1.gitLoad)(config);
588
+ idRegistryService.init(gitConf.repoPath);
589
+ idRegistryService.ensureMinimumPRCounter(minCounter);
590
+ idRegistryService.save();
591
+ }
592
+ /**
593
+ * Alloue le prochain numéro de PR en réalignant d'abord le compteur registre
594
+ * sur les branches de validation existantes.
595
+ *
596
+ * @param git Instance Git
597
+ * @param validationPrefix Préfixe des branches de validation
598
+ * @param config Configuration Git optionnelle
599
+ * @param minValue Valeur plancher du compteur PR (60 par défaut)
600
+ * @returns Prochain numéro de PR
601
+ */
602
+ async function gitAllocateNextPRNumber(git, validationPrefix, config, minValue = 60) {
603
+ const gitConf = (0, repo_tools_1.gitLoad)(config);
604
+ idRegistryService.init(gitConf.repoPath);
605
+ const allBranches = await (0, repo_tools_1.gitGetAllBranches)(git);
606
+ const numbers = allBranches
607
+ .filter((branch) => branch.startsWith(validationPrefix))
608
+ .map((branch) => {
609
+ const numberPart = branch.substring(validationPrefix.length);
610
+ return parseInt(numberPart.split('-')[0], 10);
611
+ })
612
+ .filter((num) => !isNaN(num));
613
+ const maxBranchNumber = numbers.length > 0 ? Math.max(...numbers) : minValue;
614
+ idRegistryService.ensureMinimumPRCounter(maxBranchNumber);
615
+ const nextPR = idRegistryService.generateNextPRNumber(minValue);
616
+ idRegistryService.save();
617
+ return nextPR;
618
+ }
429
619
  /**
430
620
  * Force le rechargement du registre d'IDs depuis le disque
431
621
  *
@@ -442,6 +632,8 @@ function gitGenerateNextID(config) {
442
632
  * await gitCreateOrEditFile(...); // Modifie le registre
443
633
  * gitReloadIDRegistry(); // Force le rechargement depuis le disque
444
634
  * ```
635
+ *
636
+ * @deprecated Réservé aux tests de bas niveau. Ne pas utiliser dans le workflow applicatif.
445
637
  */
446
638
  function gitReloadIDRegistry(config) {
447
639
  const gitConf = (0, repo_tools_1.gitLoad)(config);
@@ -454,6 +646,9 @@ function gitReloadIDRegistry(config) {
454
646
  /**
455
647
  * Enregistre un ID existant dans le registre
456
648
  *
649
+ * @deprecated Préférer `gitCreateOrEditFile(...)`, `gitUpdateMatter(...)` ou
650
+ * `gitRenameFile(...)` qui synchronisent le registre dans le même flux documentaire.
651
+ *
457
652
  * @param matter Le matter contenant l'ID à enregistrer
458
653
  * @param branch Branche du fichier (optionnel, pour vérification de propriété)
459
654
  * @param file Nom du fichier (optionnel, pour vérification de propriété)
@@ -477,8 +672,9 @@ function gitRegisterExistingID(matter, branch, file, config) {
477
672
  // Si l'ID existe déjà, vérifier qu'il appartient au MÊME fichier
478
673
  const registry = idRegistryService.getRegistry();
479
674
  if (registry.used.includes(matter.id)) {
480
- const ownerFile = idRegistryService.getFileByID(matter.id);
481
- if (ownerFile && ownerFile !== file) {
675
+ const conflictingOwner = idRegistryService.getMatterCachesByID(matter.id)
676
+ .find(owner => owner.file !== file);
677
+ if (conflictingOwner?.file) {
482
678
  // ID utilisé par un AUTRE fichier → Erreur
483
679
  const error = new Error(`ID ${matter.id} est déjà utilisé`);
484
680
  error.code = 'id_already_used';
@@ -497,6 +693,8 @@ function gitRegisterExistingID(matter, branch, file, config) {
497
693
  /**
498
694
  * Renomme un fichier dans le cache du registre d'IDs
499
695
  *
696
+ * @deprecated Le cache de matter/ID est maintenant mis à jour par `gitRenameFile(...)`.
697
+ *
500
698
  * Cette fonction met à jour le cache lorsqu'un fichier est renommé:
501
699
  * - Supprime l'entrée avec l'ancien nom
502
700
  * - Crée une nouvelle entrée avec le nouveau nom
@@ -516,7 +714,102 @@ function gitRegisterExistingID(matter, branch, file, config) {
516
714
  function gitIDRegistryRename(oldFile, newFile, branch, config) {
517
715
  const gitConf = (0, repo_tools_1.gitLoad)(config);
518
716
  idRegistryService.init(gitConf.repoPath);
519
- idRegistryService.rename(oldFile, newFile, branch);
717
+ const cached = idRegistryService.getMatterCacheByFile(branch, oldFile);
718
+ if (!cached?.id) {
719
+ return;
720
+ }
721
+ idRegistryService.rename(cached.id, newFile, branch);
722
+ idRegistryService.save();
723
+ }
724
+ function gitGetMatterCache(branch, id, config) {
725
+ const gitConf = (0, repo_tools_1.gitLoad)(config);
726
+ idRegistryService.init(gitConf.repoPath);
727
+ const cached = idRegistryService.getMatterCache(branch, id);
728
+ if (!cached) {
729
+ return undefined;
730
+ }
731
+ return {
732
+ id: cached.id,
733
+ file: cached.file,
734
+ title: cached.title,
735
+ service: cached.service
736
+ };
737
+ }
738
+ function gitSetMatterCache(branch, file, matter, config) {
739
+ const gitConf = (0, repo_tools_1.gitLoad)(config);
740
+ idRegistryService.init(gitConf.repoPath);
741
+ idRegistryService.setMatterCache(branch, file, matter);
742
+ idRegistryService.save();
743
+ }
744
+ function isRulesGitConfig(value) {
745
+ return !!value
746
+ && typeof value === 'object'
747
+ && 'instance' in value
748
+ && 'repoPath' in value
749
+ && 'mainBranch' in value;
750
+ }
751
+ function resolveDocumentMutationOptions(options) {
752
+ if (isRulesGitConfig(options)) {
753
+ return { config: options };
754
+ }
755
+ return options ?? {};
756
+ }
757
+ function buildDocumentMutationContent(currentContent, nextContent, matterPatch) {
758
+ const currentParsed = (0, utils_matter_1.matterParse)(currentContent);
759
+ const hasMatterPatch = !!matterPatch && Object.keys(matterPatch).length > 0;
760
+ if (!nextContent && !hasMatterPatch) {
761
+ return {
762
+ content: currentContent,
763
+ matter: currentParsed.matter
764
+ };
765
+ }
766
+ const candidateParsed = nextContent ? (0, utils_matter_1.matterParse)(nextContent) : currentParsed;
767
+ const matter = {
768
+ ...currentParsed.matter,
769
+ ...candidateParsed.matter,
770
+ ...(matterPatch ?? {})
771
+ };
772
+ return {
773
+ content: (0, utils_matter_1.matterSerialize)(candidateParsed.content, matter),
774
+ matter
775
+ };
776
+ }
777
+ function assertUpdateMatterDoesNotRequireRename(filePath, matterPatch, finalMatter) {
778
+ if (!matterPatch || !Object.prototype.hasOwnProperty.call(matterPatch, 'title')) {
779
+ return;
780
+ }
781
+ const nextTitle = finalMatter.title?.trim();
782
+ if (!nextTitle) {
783
+ throw new errors_1.GitOperationError('Le matter doit contenir un champ "title" de type string', 'missing_title', { filePath, matter: finalMatter });
784
+ }
785
+ const currentSlug = (0, utils_slug_1.slugFromFile)(filePath);
786
+ const nextSlug = (0, utils_1.toSlug)(nextTitle);
787
+ if (nextSlug !== currentSlug) {
788
+ throw new errors_1.GitOperationError(`La mutation du matter nécessite un rename explicite: ${filePath} -> ${nextSlug}.md`, 'rename_required', { filePath, currentSlug, nextSlug, title: nextTitle });
789
+ }
790
+ }
791
+ function assertRenameMatterMatchesFile(newFileName, finalMatter) {
792
+ const nextTitle = finalMatter.title?.trim();
793
+ if (!nextTitle) {
794
+ return;
795
+ }
796
+ const expectedSlug = (0, utils_1.toSlug)(nextTitle);
797
+ const targetSlug = (0, utils_slug_1.slugFromFile)(newFileName);
798
+ if (expectedSlug !== targetSlug) {
799
+ throw new errors_1.GitOperationError(`Le titre final "${nextTitle}" ne correspond pas au fichier "${newFileName}"`, 'rename_matter_mismatch', { newFileName, expectedSlug, targetSlug, title: nextTitle });
800
+ }
801
+ }
802
+ function syncMatterCacheAfterRename(branch, oldFileName, newFileName, matter, config) {
803
+ const gitConf = (0, repo_tools_1.gitLoad)(config);
804
+ idRegistryService.init(gitConf.repoPath);
805
+ idRegistryService.deleteMatterCacheByFile(branch, oldFileName);
806
+ if (typeof matter.id === 'number' && Number.isFinite(matter.id)) {
807
+ idRegistryService.setMatterCache(branch, newFileName, {
808
+ id: matter.id,
809
+ title: matter.title,
810
+ service: matter.service
811
+ });
812
+ }
520
813
  idRegistryService.save();
521
814
  }
522
815
  /**
@@ -538,17 +831,21 @@ function gitEnsureMatterID(matter, config, branch, file) {
538
831
  // ✅ NOUVEAU: Chercher d'abord l'ID existant du fichier (peu importe la branche)
539
832
  const existingFileID = file ? idRegistryService.getFileID(file) : undefined;
540
833
  if (existingFileID) {
541
- // Le fichier a déjà un ID le réutiliser pour garantir la cohérence
542
- matter.id = existingFileID;
543
- // Mettre à jour le cache si branch est définie (sinon ce sera fait plus tard)
544
- if (branch && file && matter.title) {
545
- idRegistryService.setMatterCache(branch, file, {
546
- id: matter.id,
547
- title: matter.title,
548
- service: matter.service
549
- });
834
+ // Le fichier a déjà un ID candidat dans le cache.
835
+ // Ne le réutiliser que s'il est bien arbitré pour ce fichier.
836
+ const owner = branch ? idRegistryService.getMatterCache(branch, existingFileID) : undefined;
837
+ if (!owner?.file || owner.file === file) {
838
+ matter.id = existingFileID;
839
+ // Mettre à jour le cache si branch est définie (sinon ce sera fait plus tard)
840
+ if (branch && file && matter.title) {
841
+ idRegistryService.setMatterCache(branch, file, {
842
+ id: matter.id,
843
+ title: matter.title,
844
+ service: matter.service
845
+ });
846
+ }
847
+ return matter;
550
848
  }
551
- return matter;
552
849
  }
553
850
  // Si matter a un ID fourni manuellement (ex: import ou test)
554
851
  // FIXME 999 should be a constant in config
@@ -605,7 +902,7 @@ function validateMatter(matter) {
605
902
  */
606
903
  function extractMatterFromContent(content) {
607
904
  if (!content) {
608
- return { id: 0, title: undefined, service: undefined, oldfile: undefined };
905
+ return { id: 0, title: undefined, service: undefined };
609
906
  }
610
907
  // Regex pour extraire id (format: "id: 1234" ou "id: '1234'")
611
908
  const idMatch = content.match(/^id:\s*['"]?(\d+)['"]?/m);
@@ -615,11 +912,7 @@ function extractMatterFromContent(content) {
615
912
  const title = titleMatch ? titleMatch[1].trim() : undefined;
616
913
  const serviceMatch = content.match(/^service:\s*["']?([^"'\n]+)["']?/m);
617
914
  const service = serviceMatch ? serviceMatch[1].trim() : undefined;
618
- //
619
- // ✅ Extraction de oldfile (champ temporaire pour notifier les renames)
620
- const oldfileMatch = content.match(/^oldfile:\s*["']?([^"'\n]+)["']?/m);
621
- const oldfile = oldfileMatch ? oldfileMatch[1].trim() : undefined;
622
- return { id, title, service, oldfile };
915
+ return { id, title, service };
623
916
  }
624
917
  /**
625
918
  * Lecture rapide et stricte du matter d'un fichier (id + title uniquement)
@@ -649,19 +942,18 @@ async function gitFileStrictMatter(git, filePath, branch, config) {
649
942
  idRegistryService.init(gitConf.repoPath);
650
943
  try {
651
944
  // ✅ ÉTAPE 1: Vérifier le cache
652
- const cached = idRegistryService.getMatterCache(branch, filePath);
945
+ const cached = idRegistryService.getMatterCacheByFile(branch, filePath);
653
946
  if (cached) {
654
947
  // Cache trouvé, retourner directement
655
948
  return {
656
949
  id: cached.id,
657
- title: cached.title,
658
- oldfile: cached.oldfile // ✅ Inclure oldfile temporaire
950
+ title: cached.title
659
951
  };
660
952
  }
661
953
  // ❌ ÉTAPE 2: Cache absent, lire le fichier
662
954
  const content = await git.show([`${branch}:${filePath}`]);
663
955
  if (!content) {
664
- return { id: undefined, title: undefined, oldfile: undefined };
956
+ return { id: undefined, title: undefined };
665
957
  }
666
958
  // ✅ ÉTAPE 3: Extraction rapide avec regex (sans matterParse complet)
667
959
  const matter = extractMatterFromContent(content);
@@ -674,7 +966,7 @@ async function gitFileStrictMatter(git, filePath, branch, config) {
674
966
  if (gitConf.verbose) {
675
967
  console.warn(`⚠️ Erreur lecture matter de ${filePath}:`, error);
676
968
  }
677
- return { id: undefined, title: undefined, oldfile: undefined };
969
+ return { id: undefined, title: undefined };
678
970
  }
679
971
  }
680
972
  /**
@@ -1109,8 +1401,8 @@ async function gitCreateOrEditFile(git, filePath, PR, content, user, config) {
1109
1401
  idRegistryService.init(gitConf.repoPath);
1110
1402
  idRegistryService.setMatterCache(PR, filePath, {
1111
1403
  id: parsed.matter.id,
1112
- title: parsed.matter.title
1113
- // ⚠️ Ne PAS persister oldfile dans le cache - temporaire uniquement pour client
1404
+ title: parsed.matter.title,
1405
+ service: parsed.matter.service
1114
1406
  });
1115
1407
  idRegistryService.save();
1116
1408
  }
@@ -1134,8 +1426,18 @@ async function gitCreateOrEditFile(git, filePath, PR, content, user, config) {
1134
1426
  }
1135
1427
  // Determine the current head after the commit attempt.
1136
1428
  const newHead = (await git.revparse(['HEAD'])).trim();
1137
- let newFiles = await (0, repo_tools_1.gitGetDiffFiles)(git, PR, oldNote.mergeBase);
1138
- newFiles = await sanitizePRFiles(git, newFiles, PR);
1429
+ // IMPORTANT:
1430
+ // Le scope d'une PR doit rester le delta métier déclaré (fichiers déjà trackés + fichier courant),
1431
+ // pas un recalcul global de diff qui peut gonfler metadata.files si mergeBase est absent/ancien.
1432
+ const previousFiles = Array.isArray(oldNote.files) ? oldNote.files : [];
1433
+ const mergedFiles = [...previousFiles, filePath];
1434
+ let newFiles = await sanitizePRFiles(git, mergedFiles, PR);
1435
+ // Fallback de compat pour anciennes notes sans files
1436
+ if (newFiles.length === 0) {
1437
+ const safeBase = oldNote.mergeBase || gitConf.draftBranch;
1438
+ newFiles = await (0, repo_tools_1.gitGetDiffFiles)(git, PR, safeBase);
1439
+ newFiles = await sanitizePRFiles(git, newFiles, PR);
1440
+ }
1139
1441
  const updatedNote = {
1140
1442
  ...oldNote,
1141
1443
  files: newFiles,
@@ -1180,6 +1482,9 @@ async function gitCreateOrEditFile(git, filePath, PR, content, user, config) {
1180
1482
  }
1181
1483
  /**
1182
1484
  * Modifie un fichier dans la branche draft (opération bas niveau)
1485
+ *
1486
+ * @deprecated Utiliser gitCreateOrEditFile() qui couvre création + édition.
1487
+ *
1183
1488
  * @param git Instance Git
1184
1489
  * @param filePath Chemin du fichier
1185
1490
  * @param PR Nom de la branche de Pull Request
@@ -1195,7 +1500,57 @@ async function gitEditFile(git, filePath, PR, content, user, config) {
1195
1500
  }
1196
1501
  return await gitCreateOrEditFile(git, filePath, PR, content, user, config);
1197
1502
  }
1198
- async function gitRenameFile_git(git, oldFileName, newFileName, branch, user, config) {
1503
+ /**
1504
+ * Met à jour le front-matter d'un document sans changer son nom de fichier.
1505
+ *
1506
+ * Cette API refuse explicitement toute mutation du `title` qui nécessiterait
1507
+ * un changement de slug / filename. Dans ce cas, utiliser `gitRenameFile(...)`.
1508
+ */
1509
+ async function gitUpdateMatter(git, filePath, branch, user, options) {
1510
+ const resolvedOptions = resolveDocumentMutationOptions(options);
1511
+ const gitConf = (0, repo_tools_1.gitLoad)(resolvedOptions.config);
1512
+ let currentContent;
1513
+ if (branch.toLowerCase() === 'new') {
1514
+ if (!gitConf.uploadPath) {
1515
+ throw new errors_1.GitOperationError('uploadPath is required for NEW document mutations', 'missing_upload_path', { filePath, branch });
1516
+ }
1517
+ const fullPath = (0, path_1.join)(gitConf.uploadPath, filePath);
1518
+ if (!(0, fs_1.existsSync)(fullPath)) {
1519
+ throw new errors_1.FileNotFoundError(filePath, branch);
1520
+ }
1521
+ currentContent = await fs.readFile(fullPath, 'utf8');
1522
+ }
1523
+ else {
1524
+ const currentDocument = await (0, repo_tools_1.gitGetFileContent)(git, filePath, branch);
1525
+ currentContent = currentDocument?.content;
1526
+ }
1527
+ if (!currentContent) {
1528
+ throw new errors_1.FileNotFoundError(filePath, branch);
1529
+ }
1530
+ const finalDocument = buildDocumentMutationContent(currentContent, undefined, resolvedOptions.matter);
1531
+ assertUpdateMatterDoesNotRequireRename(filePath, resolvedOptions.matter, finalDocument.matter);
1532
+ if (branch.toLowerCase() === 'new') {
1533
+ const fullPath = (0, path_1.join)(gitConf.uploadPath, filePath);
1534
+ await fs.writeFile(fullPath, finalDocument.content, { flag: 'w', encoding: 'utf8' });
1535
+ return {
1536
+ hash: '',
1537
+ date: new Date(),
1538
+ message: `update matter: ${filePath}`,
1539
+ author: {
1540
+ name: user.name,
1541
+ email: user.email
1542
+ },
1543
+ branch,
1544
+ content: finalDocument.content
1545
+ };
1546
+ }
1547
+ const commit = await gitCreateOrEditFile(git, filePath, branch, finalDocument.content, user, gitConf);
1548
+ return {
1549
+ ...commit,
1550
+ content: finalDocument.content
1551
+ };
1552
+ }
1553
+ async function gitRenameFile_git(git, oldFileName, newFileName, branch, user, finalContent, config) {
1199
1554
  // Si les noms sont identiques, pas besoin de renommer
1200
1555
  if (oldFileName === newFileName) {
1201
1556
  return {
@@ -1223,6 +1578,12 @@ async function gitRenameFile_git(git, oldFileName, newFileName, branch, user, co
1223
1578
  await git.checkout(branch);
1224
1579
  // Rename file with git mv (atomique dans Git)
1225
1580
  await git.raw(['mv', oldFileName, newFileName]);
1581
+ if (typeof finalContent === 'string') {
1582
+ const fullNewPath = (0, path_1.join)(gitConf.repoPath, newFileName);
1583
+ await fs.mkdir((0, path_1.dirname)(fullNewPath), { recursive: true });
1584
+ await fs.writeFile(fullNewPath, finalContent, { flag: 'w', encoding: 'utf8' });
1585
+ await git.add(newFileName);
1586
+ }
1226
1587
  // Commit - git mv automatically stages changes
1227
1588
  const commit = await git.commit(`rename: ${oldFileName} → ${newFileName}`, {
1228
1589
  '--author': `${user.name} <${user.email}>`
@@ -1235,17 +1596,11 @@ async function gitRenameFile_git(git, oldFileName, newFileName, branch, user, co
1235
1596
  name: user.name,
1236
1597
  email: user.email
1237
1598
  },
1238
- branch: branch
1599
+ branch: branch,
1600
+ content: finalContent
1239
1601
  };
1240
1602
  }
1241
1603
  catch (error) {
1242
- // En cas d'erreur, essayer de restaurer l'état initial
1243
- try {
1244
- await git.reset(['--hard']);
1245
- }
1246
- catch (resetError) {
1247
- console.warn('Could not reset after failed rename:', resetError);
1248
- }
1249
1604
  throw error;
1250
1605
  }
1251
1606
  finally {
@@ -1255,7 +1610,7 @@ async function gitRenameFile_git(git, oldFileName, newFileName, branch, user, co
1255
1610
  (0, repo_tools_1.unlock)(`checkout`);
1256
1611
  }
1257
1612
  }
1258
- async function gitRenameFile_fs(git, oldFileName, newFileName, branch, user, config) {
1613
+ async function gitRenameFile_fs(git, oldFileName, newFileName, branch, user, finalContent, config) {
1259
1614
  // Si les noms sont identiques, pas besoin de renommer
1260
1615
  if (oldFileName === newFileName) {
1261
1616
  return {
@@ -1269,6 +1624,9 @@ async function gitRenameFile_fs(git, oldFileName, newFileName, branch, user, con
1269
1624
  const gitConf = (0, repo_tools_1.gitLoad)(config);
1270
1625
  try {
1271
1626
  await (0, repo_tools_1.lock)(`checkout`);
1627
+ if (!gitConf.uploadPath) {
1628
+ throw new errors_1.GitOperationError('uploadPath is required for filesystem rename', 'missing_upload_path', { oldFileName, newFileName, branch });
1629
+ }
1272
1630
  const fullNewPath = (0, path_1.join)(gitConf.uploadPath, newFileName);
1273
1631
  const fullOldPath = (0, path_1.join)(gitConf.uploadPath, oldFileName);
1274
1632
  // Vérifier que l'ancien fichier existe
@@ -1282,10 +1640,13 @@ async function gitRenameFile_fs(git, oldFileName, newFileName, branch, user, con
1282
1640
  throw new Error(`Le fichier de destination "${newFileName}" existe déjà sur le système de fichiers`);
1283
1641
  }
1284
1642
  // Renommage atomique filesystem
1285
- // Utiliser copyFile + unlink pour plus de sécurité que rename() sur certains systèmes
1286
1643
  try {
1644
+ await fs.mkdir((0, path_1.dirname)(fullNewPath), { recursive: true });
1287
1645
  await fs.copyFile(fullOldPath, fullNewPath);
1288
1646
  await fs.unlink(fullOldPath);
1647
+ if (typeof finalContent === 'string') {
1648
+ await fs.writeFile(fullNewPath, finalContent, { flag: 'w', encoding: 'utf8' });
1649
+ }
1289
1650
  }
1290
1651
  catch (copyError) {
1291
1652
  // En cas d'erreur, nettoyer le nouveau fichier s'il a été créé
@@ -1307,7 +1668,8 @@ async function gitRenameFile_fs(git, oldFileName, newFileName, branch, user, con
1307
1668
  name: user.name,
1308
1669
  email: user.email
1309
1670
  },
1310
- branch: branch
1671
+ branch: branch,
1672
+ content: finalContent
1311
1673
  };
1312
1674
  }
1313
1675
  catch (error) {
@@ -1327,7 +1689,7 @@ async function gitRenameFile_fs(git, oldFileName, newFileName, branch, user, con
1327
1689
  * @param config Configuration Git optionnelle
1328
1690
  * @returns Historique du commit de renommage
1329
1691
  */
1330
- async function gitRenameFile(git, oldFileName, newFileName, branch, user, config) {
1692
+ async function gitRenameFile(git, oldFileName, newFileName, branch, user, options, nextContent) {
1331
1693
  // Si les noms sont identiques, pas besoin de renommer
1332
1694
  if (oldFileName === newFileName) {
1333
1695
  return {
@@ -1338,37 +1700,86 @@ async function gitRenameFile(git, oldFileName, newFileName, branch, user, config
1338
1700
  branch: branch
1339
1701
  };
1340
1702
  }
1341
- // load config
1342
- const gitConf = (0, repo_tools_1.gitLoad)(config);
1343
- // Pour les fichiers dans Git, utiliser Git uniquement (pas de fallback filesystem)
1344
- try {
1345
- //
1346
- //FIXME: add a check about the branch (file can be NEW and on branch)
1347
- const fullOldPath = (0, path_1.join)(gitConf.uploadPath, oldFileName);
1348
- let result;
1349
- if ((0, fs_1.existsSync)(fullOldPath)) {
1350
- result = await gitRenameFile_fs(git, oldFileName, newFileName, branch, user, config);
1351
- }
1352
- else {
1353
- result = await gitRenameFile_git(git, oldFileName, newFileName, branch, user, config);
1354
- if (branch !== 'NEW' && branch.startsWith(gitConf.validationPrefix)) {
1355
- try {
1356
- await (0, repo_pr_1.gitPRReplaceFile)(git, branch, { remove: oldFileName, add: newFileName }, gitConf);
1357
- }
1358
- catch (metadataError) {
1359
- if (gitConf.verbose) {
1360
- console.warn('⚠️ Failed to update PR metadata after rename:', metadataError);
1361
- }
1703
+ const resolvedOptions = resolveDocumentMutationOptions(options);
1704
+ const gitConf = (0, repo_tools_1.gitLoad)(resolvedOptions.config);
1705
+ if (branch.toLowerCase() === 'new' && !gitConf.uploadPath) {
1706
+ throw new errors_1.GitOperationError('uploadPath is required for NEW renames', 'missing_upload_path', { oldFileName, newFileName, branch });
1707
+ }
1708
+ const fullOldPath = gitConf.uploadPath ? (0, path_1.join)(gitConf.uploadPath, oldFileName) : '';
1709
+ const useFilesystem = branch.toLowerCase() === 'new'
1710
+ || (!!gitConf.uploadPath && (0, fs_1.existsSync)(fullOldPath) && !(await (0, repo_tools_1.gitFileExistsInBranch)(git, oldFileName, branch)));
1711
+ const currentContent = useFilesystem
1712
+ ? await fs.readFile(fullOldPath, 'utf8')
1713
+ : (await (0, repo_tools_1.gitGetFileContent)(git, oldFileName, branch))?.content;
1714
+ if (!currentContent) {
1715
+ throw new errors_1.FileNotFoundError(oldFileName, branch);
1716
+ }
1717
+ const finalDocument = buildDocumentMutationContent(currentContent, nextContent, resolvedOptions.matter);
1718
+ assertRenameMatterMatchesFile(newFileName, finalDocument.matter);
1719
+ let result;
1720
+ if (useFilesystem) {
1721
+ result = await gitRenameFile_fs(git, oldFileName, newFileName, branch, user, finalDocument.content, gitConf);
1722
+ }
1723
+ else {
1724
+ result = await gitRenameFile_git(git, oldFileName, newFileName, branch, user, finalDocument.content, gitConf);
1725
+ if (branch.startsWith(gitConf.validationPrefix)) {
1726
+ try {
1727
+ await gitPRReplaceFile(git, branch, { remove: oldFileName, add: newFileName }, gitConf);
1728
+ }
1729
+ catch (metadataError) {
1730
+ if (gitConf.verbose) {
1731
+ console.warn('⚠️ Failed to update PR metadata after rename:', metadataError);
1362
1732
  }
1363
1733
  }
1364
1734
  }
1365
- return result;
1735
+ syncMatterCacheAfterRename(branch, oldFileName, newFileName, finalDocument.matter, gitConf);
1366
1736
  }
1367
- catch (error) {
1368
- // Pour les vraies branches Git, ne pas faire de fallback filesystem
1369
- // Seules les branches NEW utilisent le filesystem
1370
- throw error;
1737
+ return {
1738
+ ...result,
1739
+ content: finalDocument.content
1740
+ };
1741
+ }
1742
+ /**
1743
+ * Met à jour la liste des fichiers d'une PR après un renommage.
1744
+ * Helper local pour éviter une dépendance circulaire avec repo.pr.ts.
1745
+ */
1746
+ async function gitPRReplaceFile(git, branch, options = {}, config) {
1747
+ const { remove, add } = options;
1748
+ if (!remove && !add) {
1749
+ return;
1750
+ }
1751
+ const gitConf = (0, repo_tools_1.gitLoad)(config);
1752
+ const metadata = await (0, repo_tools_1.gitReadNote)(git, branch, gitConf.gitNotes.namespace, 20);
1753
+ if (!metadata) {
1754
+ return;
1755
+ }
1756
+ const currentFiles = Array.isArray(metadata.files) ? metadata.files : [];
1757
+ const seen = new Set();
1758
+ const nextFiles = [];
1759
+ for (const file of currentFiles) {
1760
+ if (!file) {
1761
+ continue;
1762
+ }
1763
+ if (remove && file === remove) {
1764
+ continue;
1765
+ }
1766
+ if (seen.has(file)) {
1767
+ continue;
1768
+ }
1769
+ seen.add(file);
1770
+ nextFiles.push(file);
1771
+ }
1772
+ if (add && !seen.has(add)) {
1773
+ nextFiles.push(add);
1774
+ }
1775
+ const changed = currentFiles.length !== nextFiles.length ||
1776
+ currentFiles.some((file, idx) => file !== nextFiles[idx]);
1777
+ if (!changed) {
1778
+ return;
1371
1779
  }
1780
+ metadata.files = nextFiles;
1781
+ const headCommitHash = (await git.revparse([branch])).trim();
1782
+ await (0, repo_tools_1.gitWriteNote)(git, headCommitHash, metadata, gitConf.gitNotes.namespace);
1372
1783
  }
1373
1784
  /**
1374
1785
  * Supprime un fichier d'une branche Git (opération bas niveau)
@@ -1412,7 +1823,18 @@ async function gitDeleteFile(git, filePath, branch, user, config) {
1412
1823
  // Mettre à jour la note si elle existe (pour les branches PR)
1413
1824
  if (oldNote) {
1414
1825
  const newHead = (await git.revparse(['HEAD'])).trim();
1415
- const newFiles = await (0, repo_tools_1.gitGetDiffFiles)(git, branch, oldNote.mergeBase);
1826
+ // IMPORTANT:
1827
+ // Conserver metadata.files piloté par les opérations métier explicites.
1828
+ // Ici: suppression => on retire seulement le fichier supprimé, puis on sanitize.
1829
+ const previousFiles = Array.isArray(oldNote.files) ? oldNote.files : [];
1830
+ const mergedFiles = previousFiles.filter((f) => f !== filePath);
1831
+ let newFiles = await sanitizePRFiles(git, mergedFiles, branch);
1832
+ // Fallback de compat pour anciennes notes sans files
1833
+ if (newFiles.length === 0 && previousFiles.length === 0) {
1834
+ const safeBase = oldNote.mergeBase || gitConf.draftBranch;
1835
+ newFiles = await (0, repo_tools_1.gitGetDiffFiles)(git, branch, safeBase);
1836
+ newFiles = await sanitizePRFiles(git, newFiles, branch);
1837
+ }
1416
1838
  const updatedNote = {
1417
1839
  ...oldNote,
1418
1840
  files: newFiles,
@@ -1468,6 +1890,8 @@ const _writeFileAndCommit = async (git, filePath, content, user, config, commitM
1468
1890
  /**
1469
1891
  * Diagnostique l'état de santé d'une branche Git et détecte les problèmes bloquants.
1470
1892
  *
1893
+ * @deprecated Utiliser GitHealthManager pour les diagnostics et réparations.
1894
+ *
1471
1895
  * **Problèmes bloquants détectés :**
1472
1896
  * - ❌ **Branche inexistante** : La branche spécifiée n'existe pas dans le dépôt
1473
1897
  * - ❌ **Branche inaccessible** : Impossible de faire un checkout vers la branche
@@ -1593,6 +2017,8 @@ async function gitGetBranchHealth(git, branch) {
1593
2017
  /**
1594
2018
  * Version optimisée de gitGetBranchHealth pour les branches de validation
1595
2019
  *
2020
+ * @deprecated Utiliser GitHealthManager.diagnoseValidationBranches() / repairValidationBranches().
2021
+ *
1596
2022
  * Cette fonction utilise une approche en cascade pour diagnostiquer rapidement
1597
2023
  * l'état d'une branche de validation sans effectuer de checkout coûteux.
1598
2024
  *
@@ -1700,6 +2126,11 @@ async function checkMergeInProgress(repoPath) {
1700
2126
  return mergeFiles.some(file => (0, fs_1.existsSync)((0, path_1.join)(gitDir, file)));
1701
2127
  }
1702
2128
  async function sanitizePRFiles(git, files, branch) {
2129
+ // TODO(PR scope): Clarifier le besoin métier.
2130
+ // - Cette fonction valide seulement "existe dans la branche" + déduplication.
2131
+ // - Sur une branche créée depuis rule-editor (checkout -b), presque tous les fichiers existent déjà.
2132
+ // - Donc ce helper NE peut PAS, à lui seul, déterminer le vrai périmètre PR.
2133
+ // Source de vérité attendue: metadata.files maintenu par opérations explicites (add/edit/rename/delete).
1703
2134
  if (!files || files.length === 0) {
1704
2135
  return [];
1705
2136
  }