agentic-api 2.0.684 → 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 (56) hide show
  1. package/dist/src/agents/prompts.d.ts +2 -3
  2. package/dist/src/agents/prompts.js +13 -109
  3. package/dist/src/agents/reducer.loaders.d.ts +46 -15
  4. package/dist/src/agents/reducer.loaders.js +76 -21
  5. package/dist/src/agents/reducer.types.d.ts +30 -3
  6. package/dist/src/agents/simulator.d.ts +3 -2
  7. package/dist/src/agents/simulator.executor.d.ts +8 -2
  8. package/dist/src/agents/simulator.executor.js +62 -26
  9. package/dist/src/agents/simulator.js +100 -11
  10. package/dist/src/agents/simulator.prompts.d.ts +48 -21
  11. package/dist/src/agents/simulator.prompts.js +289 -122
  12. package/dist/src/agents/simulator.types.d.ts +33 -1
  13. package/dist/src/agents/subagent.d.ts +128 -0
  14. package/dist/src/agents/subagent.js +231 -0
  15. package/dist/src/agents/worker.executor.d.ts +48 -0
  16. package/dist/src/agents/worker.executor.js +152 -0
  17. package/dist/src/execute/helpers.d.ts +3 -0
  18. package/dist/src/execute/helpers.js +221 -15
  19. package/dist/src/execute/responses.js +78 -51
  20. package/dist/src/execute/shared.d.ts +5 -0
  21. package/dist/src/execute/shared.js +27 -0
  22. package/dist/src/index.d.ts +2 -1
  23. package/dist/src/index.js +3 -1
  24. package/dist/src/llm/openai.js +8 -1
  25. package/dist/src/llm/pricing.js +2 -0
  26. package/dist/src/llm/xai.js +11 -6
  27. package/dist/src/prompts.d.ts +14 -0
  28. package/dist/src/prompts.js +41 -1
  29. package/dist/src/rag/rag.manager.d.ts +18 -3
  30. package/dist/src/rag/rag.manager.js +91 -5
  31. package/dist/src/rules/git/git.e2e.helper.js +3 -0
  32. package/dist/src/rules/git/git.health.js +88 -57
  33. package/dist/src/rules/git/index.d.ts +1 -1
  34. package/dist/src/rules/git/index.js +13 -5
  35. package/dist/src/rules/git/repo.d.ts +25 -6
  36. package/dist/src/rules/git/repo.js +430 -146
  37. package/dist/src/rules/git/repo.pr.js +45 -13
  38. package/dist/src/rules/git/repo.tools.d.ts +5 -0
  39. package/dist/src/rules/git/repo.tools.js +6 -1
  40. package/dist/src/rules/types.d.ts +0 -2
  41. package/dist/src/rules/utils.matter.js +1 -5
  42. package/dist/src/scrapper.d.ts +138 -25
  43. package/dist/src/scrapper.js +538 -160
  44. package/dist/src/stategraph/stategraph.d.ts +4 -0
  45. package/dist/src/stategraph/stategraph.js +16 -0
  46. package/dist/src/stategraph/types.d.ts +13 -1
  47. package/dist/src/types.d.ts +21 -0
  48. package/dist/src/utils.d.ts +24 -0
  49. package/dist/src/utils.js +84 -86
  50. package/package.json +3 -2
  51. package/dist/src/agents/semantic.d.ts +0 -4
  52. package/dist/src/agents/semantic.js +0 -19
  53. package/dist/src/execute/legacy.d.ts +0 -46
  54. package/dist/src/execute/legacy.js +0 -460
  55. package/dist/src/pricing.llm.d.ts +0 -5
  56. package/dist/src/pricing.llm.js +0 -14
@@ -41,6 +41,8 @@ exports.gitAllocateNextPRNumber = gitAllocateNextPRNumber;
41
41
  exports.gitReloadIDRegistry = gitReloadIDRegistry;
42
42
  exports.gitRegisterExistingID = gitRegisterExistingID;
43
43
  exports.gitIDRegistryRename = gitIDRegistryRename;
44
+ exports.gitGetMatterCache = gitGetMatterCache;
45
+ exports.gitSetMatterCache = gitSetMatterCache;
44
46
  exports.gitEnsureMatterID = gitEnsureMatterID;
45
47
  exports.gitFileStrictMatter = gitFileStrictMatter;
46
48
  exports.gitEnsureRepositoryConfiguration = gitEnsureRepositoryConfiguration;
@@ -51,8 +53,7 @@ exports.gitShowConfiguration = gitShowConfiguration;
51
53
  exports.gitCheckConfiguration = gitCheckConfiguration;
52
54
  exports.gitCreateOrEditFile = gitCreateOrEditFile;
53
55
  exports.gitEditFile = gitEditFile;
54
- exports.gitRenameFile_git = gitRenameFile_git;
55
- exports.gitRenameFile_fs = gitRenameFile_fs;
56
+ exports.gitUpdateMatter = gitUpdateMatter;
56
57
  exports.gitRenameFile = gitRenameFile;
57
58
  exports.gitDeleteFile = gitDeleteFile;
58
59
  exports.gitGetBranchHealth = gitGetBranchHealth;
@@ -63,6 +64,8 @@ const path_1 = require("path");
63
64
  const fs = __importStar(require("fs/promises"));
64
65
  const repo_tools_1 = require("./repo.tools");
65
66
  const utils_matter_1 = require("../utils.matter");
67
+ const utils_slug_1 = require("../utils.slug");
68
+ const utils_1 = require("../../utils");
66
69
  /**
67
70
  * Service singleton pour gérer le registre d'IDs en mémoire
68
71
  */
@@ -83,6 +86,110 @@ class IDRegistryService {
83
86
  this.repoPath = '';
84
87
  this.isDirty = false;
85
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
+ }
86
193
  /**
87
194
  * Initialise le service avec le chemin du repository
88
195
  */
@@ -95,14 +202,12 @@ class IDRegistryService {
95
202
  // Si le registre existe déjà, le recharger depuis le nouveau chemin
96
203
  if (this.registry) {
97
204
  this.registry = this.loadFromDisk();
98
- this.isDirty = false;
99
205
  return;
100
206
  }
101
207
  }
102
208
  // Si le registre n'existe pas encore, le créer
103
209
  if (!this.registry) {
104
210
  this.registry = this.loadFromDisk();
105
- this.isDirty = false;
106
211
  }
107
212
  }
108
213
  /**
@@ -114,43 +219,24 @@ class IDRegistryService {
114
219
  throw new Error('IDRegistryService not initialized. Call init() first.');
115
220
  }
116
221
  this.registry = this.loadFromDisk();
117
- this.isDirty = false;
118
222
  }
119
223
  /**
120
224
  * Charge le registre depuis le disque
121
225
  */
122
226
  loadFromDisk() {
123
227
  const registryPath = (0, path_1.join)(this.repoPath, '.git', 'with-ids.json');
228
+ this.isDirty = false;
124
229
  if (!(0, fs_1.existsSync)(registryPath)) {
125
- return {
126
- last: 980,
127
- lastPR: 60,
128
- used: [],
129
- updated: new Date().toISOString(),
130
- matters: {}
131
- };
230
+ return this.createEmptyRegistry();
132
231
  }
133
232
  try {
134
233
  const data = JSON.parse((0, fs_1.readFileSync)(registryPath, 'utf8'));
135
- // Migration : ajouter matters si absent (compatibilité anciens registres)
136
- if (!data.matters) {
137
- data.matters = {};
138
- }
139
- // Migration : compteur PR indépendant (valeur initiale explicite)
140
- if (!Number.isInteger(data.lastPR)) {
141
- data.lastPR = 60;
142
- }
143
- return data;
234
+ return this.migrateRegistry(data);
144
235
  }
145
236
  catch (error) {
146
237
  console.warn('⚠️ Registre d\'IDs corrompu, création d\'un nouveau');
147
- return {
148
- last: 980,
149
- lastPR: 60,
150
- used: [],
151
- updated: new Date().toISOString(),
152
- matters: {}
153
- };
238
+ this.isDirty = false;
239
+ return this.createEmptyRegistry();
154
240
  }
155
241
  }
156
242
  /**
@@ -241,14 +327,23 @@ class IDRegistryService {
241
327
  if (!this.registry) {
242
328
  return undefined;
243
329
  }
244
- // Chercher le fichier dans toutes les branches
245
- for (const [cacheKey, matter] of Object.entries(this.registry.matters)) {
246
- const [, cachedFile] = cacheKey.split(':');
247
- if (cachedFile === file) {
248
- 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);
249
337
  }
250
338
  }
251
- 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);
252
347
  }
253
348
  /**
254
349
  * Vérifie si un ID appartient déjà à un fichier spécifique
@@ -274,18 +369,14 @@ class IDRegistryService {
274
369
  * @param id L'ID à chercher
275
370
  * @returns Le nom du fichier qui possède cet ID, ou undefined
276
371
  */
277
- getFileByID(id) {
372
+ getFileByID(branch, id) {
373
+ return this.getMatterCache(branch, id)?.file;
374
+ }
375
+ getMatterCachesByID(id) {
278
376
  if (!this.registry) {
279
- return undefined;
280
- }
281
- // Chercher l'ID dans le cache
282
- for (const [cacheKey, matter] of Object.entries(this.registry.matters)) {
283
- if (matter.id === id) {
284
- const [, file] = cacheKey.split(':');
285
- return file;
286
- }
377
+ return [];
287
378
  }
288
- return undefined;
379
+ return Object.values(this.registry.matters).filter(matter => matter.id === id);
289
380
  }
290
381
  /**
291
382
  * Enregistre un ID existant
@@ -327,23 +418,24 @@ class IDRegistryService {
327
418
  * Récupère un matter depuis le cache
328
419
  * Note: Le champ 'updated' est filtré pour ne pas être exposé à l'extérieur
329
420
  */
330
- getMatterCache(branch, file) {
421
+ getMatterCache(branch, id) {
331
422
  if (!this.registry) {
332
423
  throw new Error('IDRegistryService not initialized. Call init() first.');
333
424
  }
334
- const cacheKey = `${branch}:${file}`;
335
- const cached = this.registry.matters[cacheKey];
336
- if (!cached) {
337
- 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.');
338
431
  }
339
- // Retourner uniquement les champs publics (sans 'updated')
340
- return {
341
- id: cached.id,
342
- title: cached.title,
343
- service: cached.service,
344
- // FIXME(oldfile): compat temporaire; ne pas utiliser oldfile comme identité documentaire.
345
- oldfile: cached.oldfile
346
- };
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;
347
439
  }
348
440
  /**
349
441
  * Met à jour le cache d'un matter
@@ -352,13 +444,17 @@ class IDRegistryService {
352
444
  if (!this.registry) {
353
445
  throw new Error('IDRegistryService not initialized. Call init() first.');
354
446
  }
355
- 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);
356
453
  this.registry.matters[cacheKey] = {
357
454
  id: matter.id,
455
+ file,
358
456
  title: matter.title,
359
457
  service: matter.service,
360
- // FIXME(oldfile): compat temporaire; à retirer quand le client ne dépend plus de ce signal.
361
- oldfile: matter.oldfile,
362
458
  updated: new Date().toISOString()
363
459
  };
364
460
  this.isDirty = true;
@@ -366,14 +462,21 @@ class IDRegistryService {
366
462
  /**
367
463
  * Supprime une entrée du cache
368
464
  */
369
- deleteMatterCache(branch, file) {
465
+ deleteMatterCache(branch, id) {
370
466
  if (!this.registry) {
371
467
  throw new Error('IDRegistryService not initialized. Call init() first.');
372
468
  }
373
- const cacheKey = `${branch}:${file}`;
469
+ const cacheKey = this.createMatterKey(branch, id);
374
470
  delete this.registry.matters[cacheKey];
375
471
  this.isDirty = true;
376
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
+ }
377
480
  /**
378
481
  * Renomme une entrée dans le cache du registre
379
482
  *
@@ -392,27 +495,18 @@ class IDRegistryService {
392
495
  * idRegistryService.rename('old-name.md', 'new-name.md', 'rule-validation-1');
393
496
  * ```
394
497
  */
395
- rename(oldFile, newFile, branch) {
498
+ rename(id, newFile, branch) {
396
499
  if (!this.registry) {
397
500
  throw new Error('IDRegistryService not initialized. Call init() first.');
398
501
  }
399
- const oldKey = `${branch}:${oldFile}`;
400
- const newKey = `${branch}:${newFile}`;
401
- // Récupérer le cache existant
402
- const cached = this.registry.matters[oldKey];
502
+ const cacheKey = this.createMatterKey(branch, id);
503
+ const cached = this.registry.matters[cacheKey];
403
504
  if (!cached) {
404
- // Pas de cache pour l'ancien fichier, rien à renommer
405
505
  return;
406
506
  }
407
- // Supprimer l'ancienne clé
408
- delete this.registry.matters[oldKey];
409
- //
410
- // Créer la nouvelle clé avec les mêmes données
411
- // ⚠️ NE PAS inclure oldfile - c'est temporaire uniquement pour notification client
412
- this.registry.matters[newKey] = {
413
- id: cached.id,
414
- title: cached.title,
415
- service: cached.service,
507
+ this.registry.matters[cacheKey] = {
508
+ ...cached,
509
+ file: newFile,
416
510
  updated: new Date().toISOString()
417
511
  };
418
512
  this.isDirty = true;
@@ -552,6 +646,9 @@ function gitReloadIDRegistry(config) {
552
646
  /**
553
647
  * Enregistre un ID existant dans le registre
554
648
  *
649
+ * @deprecated Préférer `gitCreateOrEditFile(...)`, `gitUpdateMatter(...)` ou
650
+ * `gitRenameFile(...)` qui synchronisent le registre dans le même flux documentaire.
651
+ *
555
652
  * @param matter Le matter contenant l'ID à enregistrer
556
653
  * @param branch Branche du fichier (optionnel, pour vérification de propriété)
557
654
  * @param file Nom du fichier (optionnel, pour vérification de propriété)
@@ -575,8 +672,9 @@ function gitRegisterExistingID(matter, branch, file, config) {
575
672
  // Si l'ID existe déjà, vérifier qu'il appartient au MÊME fichier
576
673
  const registry = idRegistryService.getRegistry();
577
674
  if (registry.used.includes(matter.id)) {
578
- const ownerFile = idRegistryService.getFileByID(matter.id);
579
- if (ownerFile && ownerFile !== file) {
675
+ const conflictingOwner = idRegistryService.getMatterCachesByID(matter.id)
676
+ .find(owner => owner.file !== file);
677
+ if (conflictingOwner?.file) {
580
678
  // ID utilisé par un AUTRE fichier → Erreur
581
679
  const error = new Error(`ID ${matter.id} est déjà utilisé`);
582
680
  error.code = 'id_already_used';
@@ -595,6 +693,8 @@ function gitRegisterExistingID(matter, branch, file, config) {
595
693
  /**
596
694
  * Renomme un fichier dans le cache du registre d'IDs
597
695
  *
696
+ * @deprecated Le cache de matter/ID est maintenant mis à jour par `gitRenameFile(...)`.
697
+ *
598
698
  * Cette fonction met à jour le cache lorsqu'un fichier est renommé:
599
699
  * - Supprime l'entrée avec l'ancien nom
600
700
  * - Crée une nouvelle entrée avec le nouveau nom
@@ -614,7 +714,102 @@ function gitRegisterExistingID(matter, branch, file, config) {
614
714
  function gitIDRegistryRename(oldFile, newFile, branch, config) {
615
715
  const gitConf = (0, repo_tools_1.gitLoad)(config);
616
716
  idRegistryService.init(gitConf.repoPath);
617
- 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
+ }
618
813
  idRegistryService.save();
619
814
  }
620
815
  /**
@@ -636,17 +831,21 @@ function gitEnsureMatterID(matter, config, branch, file) {
636
831
  // ✅ NOUVEAU: Chercher d'abord l'ID existant du fichier (peu importe la branche)
637
832
  const existingFileID = file ? idRegistryService.getFileID(file) : undefined;
638
833
  if (existingFileID) {
639
- // Le fichier a déjà un ID le réutiliser pour garantir la cohérence
640
- matter.id = existingFileID;
641
- // Mettre à jour le cache si branch est définie (sinon ce sera fait plus tard)
642
- if (branch && file && matter.title) {
643
- idRegistryService.setMatterCache(branch, file, {
644
- id: matter.id,
645
- title: matter.title,
646
- service: matter.service
647
- });
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;
648
848
  }
649
- return matter;
650
849
  }
651
850
  // Si matter a un ID fourni manuellement (ex: import ou test)
652
851
  // FIXME 999 should be a constant in config
@@ -703,7 +902,7 @@ function validateMatter(matter) {
703
902
  */
704
903
  function extractMatterFromContent(content) {
705
904
  if (!content) {
706
- return { id: 0, title: undefined, service: undefined, oldfile: undefined };
905
+ return { id: 0, title: undefined, service: undefined };
707
906
  }
708
907
  // Regex pour extraire id (format: "id: 1234" ou "id: '1234'")
709
908
  const idMatch = content.match(/^id:\s*['"]?(\d+)['"]?/m);
@@ -713,11 +912,7 @@ function extractMatterFromContent(content) {
713
912
  const title = titleMatch ? titleMatch[1].trim() : undefined;
714
913
  const serviceMatch = content.match(/^service:\s*["']?([^"'\n]+)["']?/m);
715
914
  const service = serviceMatch ? serviceMatch[1].trim() : undefined;
716
- //
717
- // FIXME(oldfile): extraction legacy pour compat UI rename; migration cible: ID-only.
718
- const oldfileMatch = content.match(/^oldfile:\s*["']?([^"'\n]+)["']?/m);
719
- const oldfile = oldfileMatch ? oldfileMatch[1].trim() : undefined;
720
- return { id, title, service, oldfile };
915
+ return { id, title, service };
721
916
  }
722
917
  /**
723
918
  * Lecture rapide et stricte du matter d'un fichier (id + title uniquement)
@@ -747,19 +942,18 @@ async function gitFileStrictMatter(git, filePath, branch, config) {
747
942
  idRegistryService.init(gitConf.repoPath);
748
943
  try {
749
944
  // ✅ ÉTAPE 1: Vérifier le cache
750
- const cached = idRegistryService.getMatterCache(branch, filePath);
945
+ const cached = idRegistryService.getMatterCacheByFile(branch, filePath);
751
946
  if (cached) {
752
947
  // Cache trouvé, retourner directement
753
948
  return {
754
949
  id: cached.id,
755
- title: cached.title,
756
- oldfile: cached.oldfile // ✅ Inclure oldfile temporaire
950
+ title: cached.title
757
951
  };
758
952
  }
759
953
  // ❌ ÉTAPE 2: Cache absent, lire le fichier
760
954
  const content = await git.show([`${branch}:${filePath}`]);
761
955
  if (!content) {
762
- return { id: undefined, title: undefined, oldfile: undefined };
956
+ return { id: undefined, title: undefined };
763
957
  }
764
958
  // ✅ ÉTAPE 3: Extraction rapide avec regex (sans matterParse complet)
765
959
  const matter = extractMatterFromContent(content);
@@ -772,7 +966,7 @@ async function gitFileStrictMatter(git, filePath, branch, config) {
772
966
  if (gitConf.verbose) {
773
967
  console.warn(`⚠️ Erreur lecture matter de ${filePath}:`, error);
774
968
  }
775
- return { id: undefined, title: undefined, oldfile: undefined };
969
+ return { id: undefined, title: undefined };
776
970
  }
777
971
  }
778
972
  /**
@@ -1207,8 +1401,8 @@ async function gitCreateOrEditFile(git, filePath, PR, content, user, config) {
1207
1401
  idRegistryService.init(gitConf.repoPath);
1208
1402
  idRegistryService.setMatterCache(PR, filePath, {
1209
1403
  id: parsed.matter.id,
1210
- title: parsed.matter.title
1211
- // ⚠️ Ne PAS persister oldfile dans le cache - temporaire uniquement pour client
1404
+ title: parsed.matter.title,
1405
+ service: parsed.matter.service
1212
1406
  });
1213
1407
  idRegistryService.save();
1214
1408
  }
@@ -1232,8 +1426,18 @@ async function gitCreateOrEditFile(git, filePath, PR, content, user, config) {
1232
1426
  }
1233
1427
  // Determine the current head after the commit attempt.
1234
1428
  const newHead = (await git.revparse(['HEAD'])).trim();
1235
- let newFiles = await (0, repo_tools_1.gitGetDiffFiles)(git, PR, oldNote.mergeBase);
1236
- 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
+ }
1237
1441
  const updatedNote = {
1238
1442
  ...oldNote,
1239
1443
  files: newFiles,
@@ -1296,7 +1500,57 @@ async function gitEditFile(git, filePath, PR, content, user, config) {
1296
1500
  }
1297
1501
  return await gitCreateOrEditFile(git, filePath, PR, content, user, config);
1298
1502
  }
1299
- 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) {
1300
1554
  // Si les noms sont identiques, pas besoin de renommer
1301
1555
  if (oldFileName === newFileName) {
1302
1556
  return {
@@ -1324,6 +1578,12 @@ async function gitRenameFile_git(git, oldFileName, newFileName, branch, user, co
1324
1578
  await git.checkout(branch);
1325
1579
  // Rename file with git mv (atomique dans Git)
1326
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
+ }
1327
1587
  // Commit - git mv automatically stages changes
1328
1588
  const commit = await git.commit(`rename: ${oldFileName} → ${newFileName}`, {
1329
1589
  '--author': `${user.name} <${user.email}>`
@@ -1336,17 +1596,11 @@ async function gitRenameFile_git(git, oldFileName, newFileName, branch, user, co
1336
1596
  name: user.name,
1337
1597
  email: user.email
1338
1598
  },
1339
- branch: branch
1599
+ branch: branch,
1600
+ content: finalContent
1340
1601
  };
1341
1602
  }
1342
1603
  catch (error) {
1343
- // En cas d'erreur, essayer de restaurer l'état initial
1344
- try {
1345
- await git.reset(['--hard']);
1346
- }
1347
- catch (resetError) {
1348
- console.warn('Could not reset after failed rename:', resetError);
1349
- }
1350
1604
  throw error;
1351
1605
  }
1352
1606
  finally {
@@ -1356,7 +1610,7 @@ async function gitRenameFile_git(git, oldFileName, newFileName, branch, user, co
1356
1610
  (0, repo_tools_1.unlock)(`checkout`);
1357
1611
  }
1358
1612
  }
1359
- async function gitRenameFile_fs(git, oldFileName, newFileName, branch, user, config) {
1613
+ async function gitRenameFile_fs(git, oldFileName, newFileName, branch, user, finalContent, config) {
1360
1614
  // Si les noms sont identiques, pas besoin de renommer
1361
1615
  if (oldFileName === newFileName) {
1362
1616
  return {
@@ -1370,6 +1624,9 @@ async function gitRenameFile_fs(git, oldFileName, newFileName, branch, user, con
1370
1624
  const gitConf = (0, repo_tools_1.gitLoad)(config);
1371
1625
  try {
1372
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
+ }
1373
1630
  const fullNewPath = (0, path_1.join)(gitConf.uploadPath, newFileName);
1374
1631
  const fullOldPath = (0, path_1.join)(gitConf.uploadPath, oldFileName);
1375
1632
  // Vérifier que l'ancien fichier existe
@@ -1383,10 +1640,13 @@ async function gitRenameFile_fs(git, oldFileName, newFileName, branch, user, con
1383
1640
  throw new Error(`Le fichier de destination "${newFileName}" existe déjà sur le système de fichiers`);
1384
1641
  }
1385
1642
  // Renommage atomique filesystem
1386
- // Utiliser copyFile + unlink pour plus de sécurité que rename() sur certains systèmes
1387
1643
  try {
1644
+ await fs.mkdir((0, path_1.dirname)(fullNewPath), { recursive: true });
1388
1645
  await fs.copyFile(fullOldPath, fullNewPath);
1389
1646
  await fs.unlink(fullOldPath);
1647
+ if (typeof finalContent === 'string') {
1648
+ await fs.writeFile(fullNewPath, finalContent, { flag: 'w', encoding: 'utf8' });
1649
+ }
1390
1650
  }
1391
1651
  catch (copyError) {
1392
1652
  // En cas d'erreur, nettoyer le nouveau fichier s'il a été créé
@@ -1408,7 +1668,8 @@ async function gitRenameFile_fs(git, oldFileName, newFileName, branch, user, con
1408
1668
  name: user.name,
1409
1669
  email: user.email
1410
1670
  },
1411
- branch: branch
1671
+ branch: branch,
1672
+ content: finalContent
1412
1673
  };
1413
1674
  }
1414
1675
  catch (error) {
@@ -1428,7 +1689,7 @@ async function gitRenameFile_fs(git, oldFileName, newFileName, branch, user, con
1428
1689
  * @param config Configuration Git optionnelle
1429
1690
  * @returns Historique du commit de renommage
1430
1691
  */
1431
- async function gitRenameFile(git, oldFileName, newFileName, branch, user, config) {
1692
+ async function gitRenameFile(git, oldFileName, newFileName, branch, user, options, nextContent) {
1432
1693
  // Si les noms sont identiques, pas besoin de renommer
1433
1694
  if (oldFileName === newFileName) {
1434
1695
  return {
@@ -1439,37 +1700,44 @@ async function gitRenameFile(git, oldFileName, newFileName, branch, user, config
1439
1700
  branch: branch
1440
1701
  };
1441
1702
  }
1442
- // load config
1443
- const gitConf = (0, repo_tools_1.gitLoad)(config);
1444
- // Pour les fichiers dans Git, utiliser Git uniquement (pas de fallback filesystem)
1445
- try {
1446
- //
1447
- //FIXME: add a check about the branch (file can be NEW and on branch)
1448
- const fullOldPath = (0, path_1.join)(gitConf.uploadPath, oldFileName);
1449
- let result;
1450
- if ((0, fs_1.existsSync)(fullOldPath)) {
1451
- result = await gitRenameFile_fs(git, oldFileName, newFileName, branch, user, config);
1452
- }
1453
- else {
1454
- result = await gitRenameFile_git(git, oldFileName, newFileName, branch, user, config);
1455
- if (branch !== 'NEW' && branch.startsWith(gitConf.validationPrefix)) {
1456
- try {
1457
- await gitPRReplaceFile(git, branch, { remove: oldFileName, add: newFileName }, gitConf);
1458
- }
1459
- catch (metadataError) {
1460
- if (gitConf.verbose) {
1461
- console.warn('⚠️ Failed to update PR metadata after rename:', metadataError);
1462
- }
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);
1463
1732
  }
1464
1733
  }
1465
1734
  }
1466
- return result;
1467
- }
1468
- catch (error) {
1469
- // Pour les vraies branches Git, ne pas faire de fallback filesystem
1470
- // Seules les branches NEW utilisent le filesystem
1471
- throw error;
1735
+ syncMatterCacheAfterRename(branch, oldFileName, newFileName, finalDocument.matter, gitConf);
1472
1736
  }
1737
+ return {
1738
+ ...result,
1739
+ content: finalDocument.content
1740
+ };
1473
1741
  }
1474
1742
  /**
1475
1743
  * Met à jour la liste des fichiers d'une PR après un renommage.
@@ -1555,7 +1823,18 @@ async function gitDeleteFile(git, filePath, branch, user, config) {
1555
1823
  // Mettre à jour la note si elle existe (pour les branches PR)
1556
1824
  if (oldNote) {
1557
1825
  const newHead = (await git.revparse(['HEAD'])).trim();
1558
- 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
+ }
1559
1838
  const updatedNote = {
1560
1839
  ...oldNote,
1561
1840
  files: newFiles,
@@ -1847,6 +2126,11 @@ async function checkMergeInProgress(repoPath) {
1847
2126
  return mergeFiles.some(file => (0, fs_1.existsSync)((0, path_1.join)(gitDir, file)));
1848
2127
  }
1849
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).
1850
2134
  if (!files || files.length === 0) {
1851
2135
  return [];
1852
2136
  }