@sylad/cadence 1.8.0 → 1.9.0

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 (93) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +23 -7
  4. package/agents/code-reviewer.md +2 -2
  5. package/agents/doc-keeper.md +19 -0
  6. package/agents/qa-reviewer.md +2 -2
  7. package/agents/spec-writer.md +1 -1
  8. package/agents/ux-reviewer.md +1 -1
  9. package/bin/cadence.js +9 -1
  10. package/dist/audit.js +14 -4
  11. package/dist/check.js +6 -0
  12. package/dist/cli.js +235 -30
  13. package/dist/config.js +101 -5
  14. package/dist/corrections.js +23 -2
  15. package/dist/deliver.js +78 -21
  16. package/dist/docsync.js +19 -3
  17. package/dist/git.js +16 -3
  18. package/dist/lead.js +37 -12
  19. package/dist/migrate.js +166 -0
  20. package/dist/orchestrate/briefs.js +28 -6
  21. package/dist/orchestrate/command.js +303 -34
  22. package/dist/orchestrate/cycle.js +720 -273
  23. package/dist/orchestrate/guard.js +2 -2
  24. package/dist/orchestrate/integrate.js +85 -0
  25. package/dist/orchestrate/launch.js +37 -5
  26. package/dist/orchestrate/mutex.js +30 -0
  27. package/dist/orchestrate/pool.js +74 -36
  28. package/dist/orchestrate/registry.js +123 -14
  29. package/dist/orchestrate/result.js +1 -1
  30. package/dist/orchestrate/snapshot.js +25 -0
  31. package/dist/orchestrate/spec.js +89 -5
  32. package/dist/orchestrate/state.js +42 -6
  33. package/dist/orchestrate/table.js +61 -2
  34. package/dist/orchestrate/timeouts.js +40 -0
  35. package/dist/orchestrate/worktree.js +368 -0
  36. package/dist/perf.js +80 -19
  37. package/dist/plan.js +92 -6
  38. package/dist/ready-free.js +109 -0
  39. package/dist/ref/add.js +116 -0
  40. package/dist/ref/check.js +338 -0
  41. package/dist/ref/command.js +78 -0
  42. package/dist/ref/corpus-render.js +83 -0
  43. package/dist/ref/corpus.js +201 -0
  44. package/dist/ref/evaluate.js +241 -0
  45. package/dist/ref/figures.js +179 -0
  46. package/dist/ref/measure.js +330 -0
  47. package/dist/ref/pdf.js +104 -0
  48. package/dist/ref/png.js +125 -0
  49. package/dist/ref/store.js +40 -0
  50. package/dist/ref/tools.js +51 -0
  51. package/dist/resources.js +51 -0
  52. package/dist/session.js +5 -2
  53. package/dist/state.js +60 -7
  54. package/dist/testgate.js +15 -0
  55. package/docs/cadence-hud.md +12 -0
  56. package/docs/cycle-vague-dark.svg +1 -1
  57. package/docs/cycle-vague-light.svg +1 -1
  58. package/docs/deliver.md +58 -3
  59. package/docs/fonctionnement-wave-dark.svg +1 -1
  60. package/docs/fonctionnement-wave-light.svg +1 -1
  61. package/docs/fonctionnement.md +5 -5
  62. package/docs/lead.md +35 -12
  63. package/docs/news.md +24 -1
  64. package/docs/orchestrate.md +230 -19
  65. package/docs/perf.md +13 -8
  66. package/docs/raf.md +89 -11
  67. package/docs/ref.md +316 -0
  68. package/docs/session.md +2 -2
  69. package/docs/skills.md +9 -2
  70. package/docs/spec.md +1 -1
  71. package/docs/stabilite.md +4 -4
  72. package/docs/whats-new.md +2 -0
  73. package/package.json +2 -1
  74. package/skills/deliver/SKILL.md +6 -1
  75. package/skills/lead/SKILL.md +24 -6
  76. package/skills/session-close/SKILL.md +1 -1
  77. package/skills/session-start/SKILL.md +2 -2
  78. package/templates/inventaire.json +96 -0
  79. package/templates/orchestrate/accept-back.md +1 -0
  80. package/templates/orchestrate/accept.md +1 -0
  81. package/templates/orchestrate/fiche.md +1 -0
  82. package/templates/orchestrate/fix-docs.md +18 -0
  83. package/templates/orchestrate/fix-minors.md +2 -1
  84. package/templates/orchestrate/fix.md +5 -2
  85. package/templates/orchestrate/implement.md +6 -3
  86. package/templates/orchestrate/maquette.md +2 -1
  87. package/templates/orchestrate/precheck.md +2 -1
  88. package/templates/orchestrate/review-recheck.md +2 -0
  89. package/templates/orchestrate/review-small.md +5 -1
  90. package/templates/orchestrate/review.md +2 -0
  91. package/templates/orchestrate/spec-back.md +2 -1
  92. package/templates/orchestrate/spec.md +4 -3
  93. package/templates/orchestrate/ux.md +3 -1
package/dist/plan.js CHANGED
@@ -6,7 +6,18 @@ export const STATUSES = ['todo', 'doing', 'done', 'dropped'];
6
6
  export const GATE_INVALID = 'gate invalide : count: entier ≥ 1 et un critère spec: true ou screen: page attendus';
7
7
  export class RafError extends Error {
8
8
  }
9
- export const FIELDS = ['id', 'title', 'status', 'estimate', 'quickwin', 'visible', 'screen', 'spec', 'public', 'every', 'last', 'after', 'created', 'started', 'finished', 'notes', 'repos', 'parent', 'gate'];
9
+ /** Un nom de ressource partagée (L251) : un mot de lettres minuscules, chiffres, « - » et « _ ». */
10
+ export const RESOURCE_NAME = /^[a-z0-9_-]+$/;
11
+ export const RESOURCES_BAD_LIST = 'doit être une liste de noms de ressources';
12
+ export const invalidResource = (name) => `ressource invalide : ${name === '' ? '(vide)' : name} (lettres minuscules, chiffres, - et _)`;
13
+ /** Les noms de ressources de `--uses` ou de `raf uses` : chacun valide (`RafError` sinon), sans doublon, dans l'ordre donné. */
14
+ export function cleanUses(names) {
15
+ for (const n of names)
16
+ if (!RESOURCE_NAME.test(n))
17
+ throw new RafError(invalidResource(n));
18
+ return [...new Set(names)];
19
+ }
20
+ export const FIELDS = ['id', 'title', 'status', 'estimate', 'quickwin', 'visible', 'screen', 'spec', 'public', 'every', 'last', 'after', 'created', 'started', 'finished', 'notes', 'repos', 'parent', 'gate', 'goal'];
10
21
  /** Nature de l'écran d'un lot visible : nouvelle page, nouveau composant, parcours modifié. */
11
22
  export const SCREENS = ['page', 'component', 'journey'];
12
23
  export function isOpen(status) {
@@ -238,8 +249,8 @@ export class Plan {
238
249
  return this.enableGate('reviewSince', today);
239
250
  }
240
251
  /** `commit` : sha du dernier commit compté du lot (le plan ne lit pas git), null s'il n'en a aucun. */
241
- recordReview(lotId, verdict, today, commit, repos) {
242
- this.recordVerdict('review', 'la revue de code', lotId, { date: today, verdict, commit, ...(repos && Object.keys(repos).length ? { repos } : {}) });
252
+ recordReview(lotId, verdict, today, commit, repos, reporte) {
253
+ this.recordVerdict('review', 'la revue de code', lotId, { date: today, verdict, commit, ...(repos && Object.keys(repos).length ? { repos } : {}), ...(reporte ? { reporte } : {}) });
243
254
  }
244
255
  enableGate(key, today) {
245
256
  this.writable();
@@ -326,11 +337,15 @@ export class Plan {
326
337
  entry.every = opts.every;
327
338
  if (opts.after?.length)
328
339
  entry.after = opts.after;
340
+ if (opts.uses?.length)
341
+ entry.uses = cleanUses(opts.uses);
329
342
  entry.created = today;
330
343
  const node = this.doc.createNode(entry);
331
- const after = node.get('after');
332
- if (isSeq(after))
333
- after.flow = true;
344
+ for (const key of ['after', 'uses']) {
345
+ const seq = node.get(key);
346
+ if (isSeq(seq))
347
+ seq.flow = true;
348
+ }
334
349
  this.doc.get('lots').add(node);
335
350
  return id;
336
351
  }
@@ -341,6 +356,28 @@ export class Plan {
341
356
  node.flow = true;
342
357
  this.lotNode(lotId).set('repos', node);
343
358
  }
359
+ /** Remplace les ressources partagées d'un lot de tout statut (clé `uses:` en flow, avant `created:`) ; sans nom, retire la clé. Jamais sur une sous-tâche. */
360
+ setUses(ref, names) {
361
+ this.writable();
362
+ if (ref.includes('/'))
363
+ throw new RafError(`uses se pose sur un lot, pas sur une sous-tâche : ${ref}`);
364
+ const node = this.lotNode(ref);
365
+ const uses = cleanUses(names);
366
+ if (uses.length === 0) {
367
+ node.delete('uses');
368
+ return;
369
+ }
370
+ const list = this.doc.createNode(uses);
371
+ list.flow = true;
372
+ if (node.has('uses')) {
373
+ node.set('uses', list);
374
+ return;
375
+ }
376
+ const keys = node.items.map((p) => String(p.key?.value ?? p.key));
377
+ const created = keys.indexOf('created');
378
+ const after = keys.indexOf('after');
379
+ node.items.splice(created >= 0 ? created : after >= 0 ? after + 1 : keys.length, 0, this.doc.createPair('uses', list));
380
+ }
344
381
  addTask(lotId, title) {
345
382
  this.writable();
346
383
  const lot = this.lotNode(lotId);
@@ -421,6 +458,21 @@ export class Plan {
421
458
  node.delete('started');
422
459
  this.note(ref, reason.trim() ? `remis en attente : ${reason.trim()}` : 'remis en attente', today);
423
460
  }
461
+ /** Remplace le titre d'un lot, de tout statut, et note l'ancien titre ; refuse un titre vide ou identique. */
462
+ setTitle(ref, text, today) {
463
+ this.writable();
464
+ if (ref.includes('/'))
465
+ throw new RafError(`raf title se pose sur un lot, pas une sous-tâche : ${ref}`);
466
+ const title = text.trim();
467
+ if (title === '')
468
+ throw new RafError('titre vide — raf title <id> "nouveau titre"');
469
+ const node = this.lotNode(ref);
470
+ const old = String(node.get('title') ?? '');
471
+ if (old === title)
472
+ throw new RafError(`${ref} porte déjà ce titre`);
473
+ node.set('title', title);
474
+ this.note(ref, `titre remplacé : « ${old} »`, today);
475
+ }
424
476
  /** Pose, remplace (texte) ou efface (null) le titre public d'un lot ; la clé suit `title`. */
425
477
  setPublic(ref, text) {
426
478
  this.writable();
@@ -460,6 +512,27 @@ export class Plan {
460
512
  const keys = node.items.map((p) => String(p.key?.value ?? p.key));
461
513
  node.items.splice(keys.indexOf('visible') + 1, 0, this.doc.createPair('screen', screen));
462
514
  }
515
+ /** Pose (true) ou retire (false) le `visible:` d'un lot existant ; retirer est refusé tant que le lot a un `screen:` (un écran est un changement visible). */
516
+ setVisible(ref, on) {
517
+ this.writable();
518
+ if (ref.includes('/'))
519
+ throw new RafError(`visible se pose sur un lot, pas une sous-tâche : ${ref}`);
520
+ const node = this.lotNode(ref);
521
+ const lot = this.lot(ref);
522
+ if (!on) {
523
+ if (lot.screen)
524
+ throw new RafError(`${ref} a screen: ${lot.screen} — retirer d'abord screen: (raf screen ${ref} none), un écran est un changement visible`);
525
+ node.delete('visible');
526
+ return;
527
+ }
528
+ if (node.has('visible')) {
529
+ node.set('visible', true);
530
+ return;
531
+ }
532
+ const keys = node.items.map((p) => String(p.key?.value ?? p.key));
533
+ const at = Math.max(...['quickwin', 'estimate', 'status'].map((k) => keys.indexOf(k)));
534
+ node.items.splice(at + 1, 0, this.doc.createPair('visible', true));
535
+ }
463
536
  /** Pose (true) ou retire (false) le `spec:` d'un lot existant ; `screen: page` l'implique, donc on ne retire pas la spec d'un lot à page (`component` et `journey` suivent le chemin ordinaire depuis L209 : leur `spec: true` est la spec back). Renvoie false quand `screen: page` rendait la pose sans objet. */
464
537
  setSpec(ref, on) {
465
538
  this.writable();
@@ -565,6 +638,7 @@ function foreignLots(raw, format) {
565
638
  notes: typeof notes === 'string' ? [{ text: notes }] : notes,
566
639
  repos: pick('repos'),
567
640
  gate: pick('gate'),
641
+ goal: pick('goal'),
568
642
  }, problems);
569
643
  const parent = pick('parent');
570
644
  return { lot, parent: parent == null ? undefined : String(parent) };
@@ -622,6 +696,13 @@ function normalizeLot(raw, problems = []) {
622
696
  };
623
697
  const after = raw.after == null ? [] : Array.isArray(raw.after) ? raw.after.map(String) : [String(raw.after)];
624
698
  const last = asDay('last');
699
+ let uses;
700
+ if (raw.uses != null) {
701
+ if (Array.isArray(raw.uses) && raw.uses.every((n) => typeof n === 'string' && RESOURCE_NAME.test(n)))
702
+ uses = [...new Set(raw.uses)];
703
+ else
704
+ problems.push(`uses ${RESOURCES_BAD_LIST}`);
705
+ }
625
706
  let screen;
626
707
  if (raw.screen != null) {
627
708
  if (SCREENS.includes(raw.screen))
@@ -650,9 +731,11 @@ function normalizeLot(raw, problems = []) {
650
731
  ...(spec ? { spec } : {}),
651
732
  ...(gate ? { gate } : {}),
652
733
  ...(typeof raw.public === 'string' && raw.public.trim() !== '' ? { public: raw.public.trim() } : {}),
734
+ ...(typeof raw.goal === 'string' && raw.goal.trim() !== '' ? { goal: raw.goal.trim() } : {}),
653
735
  ...(Number.isInteger(raw.every) && raw.every > 0 ? { every: raw.every } : {}),
654
736
  ...(last ? { last } : {}),
655
737
  after,
738
+ ...(uses?.length ? { uses } : {}),
656
739
  created: asDay('created'),
657
740
  started: asDay('started'),
658
741
  finished: asDay('finished'),
@@ -705,5 +788,8 @@ function asVerdict(raw) {
705
788
  if (repos.length)
706
789
  verdict.repos = Object.fromEntries(repos);
707
790
  }
791
+ const r = v.reporte;
792
+ if (r && typeof r === 'object' && !Array.isArray(r) && r.de != null && r.vers != null)
793
+ verdict.reporte = { de: String(r.de), vers: String(r.vers), patchId: String(r.patchId ?? '') };
708
794
  return verdict;
709
795
  }
@@ -0,0 +1,109 @@
1
+ import { dirname, resolve } from 'node:path';
2
+ import { nextUp } from './audit.js';
3
+ import { planLoader } from './config.js';
4
+ import { gitRoot } from './git.js';
5
+ import { findProjects, readDepth } from './lead.js';
6
+ import { decisionPending } from './orchestrate/continue.js';
7
+ import { liveWaves } from './orchestrate/registry.js';
8
+ import { readResources } from './resources.js';
9
+ import { lotFinished, RunStore } from './orchestrate/state.js';
10
+ /** Le dépôt d'un projet : sa racine git, ou son chemin absolu hors de git. */
11
+ const repoOf = (dir) => gitRoot(dir) ?? resolve(dir);
12
+ /**
13
+ * Un lot se reconnaît à son dépôt et à son id, pas au nom du projet : une vague lancée depuis un autre dossier l'enregistre sous `basename(dir)`
14
+ * (le tour d'un dossier parent le nomme par son chemin, `grp/alpha`), et deux projets de même nom de deux dossiers sont deux dépôts.
15
+ */
16
+ const lotAt = (repo, lot) => `${repo}:${lot}`;
17
+ /**
18
+ * Les projets qui partagent les ressources de `dir` : ceux que trouve `findProjects` sous le dossier du cadence.yaml qui déclare ses `resources:`
19
+ * (le plus proche en remontant, voir `readResources`), sur la profondeur `depth:` de ce fichier (un niveau à défaut) : `P/x` et `P/grp/alpha`
20
+ * partagent le banc que déclare `P/cadence.yaml` (`depth: 2`), d'où qu'on les interroge. Sans ressource déclarée, ou avec un cadence.yaml
21
+ * illisible ou une `depth:` invalide (que leur ligne de tour signale), ce sont les projets du dossier parent, sur un niveau.
22
+ */
23
+ function sharingProjects(dir) {
24
+ try {
25
+ const { file, names } = readResources(repoOf(dir));
26
+ if (names.length)
27
+ return findProjects(dirname(file), readDepth(dirname(file)));
28
+ }
29
+ catch {
30
+ // cadence.yaml illisible ou depth: invalide
31
+ }
32
+ return findProjects(dirname(dir));
33
+ }
34
+ const plansOf = (dir) => planLoader(repoOf(dir))().lots();
35
+ /**
36
+ * Les ressources prises à cet instant : celles des lots `doing` des projets `dirs` et de ceux qui partagent leurs ressources (`sharingProjects` : lancé depuis l'un d'eux,
37
+ * on voit aussi les autres), celles des lots non finis des vagues vivantes (un lot de vague n'est `doing` dans le plan qu'une fois sa session partie)
38
+ * et celles des lots `held` (le tirage d'une vague qui part). Avec leurs lots (`dépôt:lot`, voir `lotAt`), qui ne sont pas à proposer.
39
+ */
40
+ function taken(dirs, home, held) {
41
+ const resources = new Set();
42
+ const running = new Set();
43
+ for (const dir of new Set([...dirs, ...dirs.flatMap(sharingProjects)])) {
44
+ try {
45
+ for (const l of nextUp(plansOf(dir)).doing)
46
+ for (const r of l.uses ?? [])
47
+ resources.add(r);
48
+ }
49
+ catch {
50
+ // plan illisible : sa ligne de tour le dit, il ne tient aucune ressource connue
51
+ }
52
+ }
53
+ for (const w of liveWaves(home)) {
54
+ let live;
55
+ try {
56
+ live = RunStore.find(w.cwd, w.wave)?.lots().filter((l) => !lotFinished(l)) ?? [];
57
+ }
58
+ catch {
59
+ continue; // état de vague illisible : la vague ne tient aucune ressource connue
60
+ }
61
+ held = [...held, ...live];
62
+ }
63
+ for (const s of held) {
64
+ running.add(lotAt(repoOf(s.repo), s.lot));
65
+ try {
66
+ for (const r of plansOf(s.repo).find((l) => l.id === s.lot)?.uses ?? [])
67
+ resources.add(r);
68
+ }
69
+ catch {
70
+ // plan illisible : aucune ressource connue
71
+ }
72
+ }
73
+ return { resources, running };
74
+ }
75
+ /**
76
+ * Pour chaque dossier de projet de `dirs`, ses lots prêts (comme `nextUp`, hors « à décider » et hors lots d'une vague vivante) dont aucune ressource
77
+ * n'est prise par un lot `doing` d'un des projets (ou de ceux qui partagent leurs ressources, voir `sharingProjects`) ni par une vague vivante de `home` ni par les lots `held` d'une vague qui part. Un lot sans `uses:` n'en tient aucune : il est libre.
78
+ * Les projets sans lot libre n'ont pas d'entrée (clé : le dossier) ; un plan illisible est sauté.
79
+ */
80
+ export function readyFree(dirs, home, held = []) {
81
+ const { resources, running } = taken(dirs, home, held);
82
+ const out = new Map();
83
+ for (const dir of dirs) {
84
+ let ready;
85
+ try {
86
+ ready = nextUp(plansOf(dir)).ready;
87
+ }
88
+ catch {
89
+ continue;
90
+ }
91
+ const repo = repoOf(dir);
92
+ const free = ready
93
+ .filter((l) => !decisionPending(l) && !running.has(lotAt(repo, l.id)) && !(l.uses ?? []).some((r) => resources.has(r)))
94
+ .map((l) => ({ id: l.id, title: l.title, ...(l.uses?.length ? { uses: l.uses } : {}) }));
95
+ if (free.length)
96
+ out.set(dir, free);
97
+ }
98
+ return out;
99
+ }
100
+ /** Lots listés par projet dans la ligne ; au-delà, « projet +K » (le --json donne tout). */
101
+ const FREE_SHOWN = 3;
102
+ /** « prêts, ressources libres : projet:L3 titre (banc) · … », au plus FREE_SHOWN lots par projet puis « projet +K », ou null quand aucun lot prêt n'est libre. */
103
+ export function freeLine(entries, titleMax) {
104
+ const items = entries.flatMap((e) => [
105
+ ...e.free.slice(0, FREE_SHOWN).map((l) => `${e.project}:${l.id} ${l.title.length <= titleMax ? l.title : `${l.title.slice(0, titleMax - 1)}…`}${l.uses?.length ? ` (${l.uses.join(', ')})` : ''}`),
106
+ ...(e.free.length > FREE_SHOWN ? [`${e.project} +${e.free.length - FREE_SHOWN}`] : []),
107
+ ]);
108
+ return items.length ? `prêts, ressources libres : ${items.join(' · ')}` : null;
109
+ }
@@ -0,0 +1,116 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readFileSync, statSync } from 'node:fs';
3
+ import { basename, extname, join, resolve } from 'node:path';
4
+ import { RafError } from '../plan.js';
5
+ import { attachRegions, citedPages, indexText } from './figures.js';
6
+ import { DPI, extractImages, imageFile, imageRegions, listImages, pageCount, renderPages, renderThumbnails, textLayout } from './pdf.js';
7
+ import { readDocument, writeDocument } from './store.js';
8
+ import { requireTools } from './tools.js';
9
+ /** Sans `--sections`, un document de cette taille ou plus petit est rendu en entier ; au-delà, seul l'index est construit. */
10
+ export const WHOLE_DOCUMENT_MAX = 30;
11
+ /** Une page de moins de 20 caractères de texte est tenue pour scannée (recette du skill norme-etude). */
12
+ const SCANNED_MAX_CHARS = 20;
13
+ /** [1, 2, 3, 6] → « 1-3, 6 ». */
14
+ function ranges(pages) {
15
+ const out = [];
16
+ for (let i = 0; i < pages.length;) {
17
+ let j = i;
18
+ while (j + 1 < pages.length && pages[j + 1] === pages[j] + 1)
19
+ j++;
20
+ out.push(j > i ? `${pages[i]}-${pages[j]}` : String(pages[i]));
21
+ i = j + 1;
22
+ }
23
+ return out.join(', ');
24
+ }
25
+ /** `3-5,9` → [3, 4, 5, 9] (pages du PDF, 1 = première), sans doublon, dans l'ordre. */
26
+ export function parseSections(text, pages) {
27
+ const out = new Set();
28
+ for (const part of text.split(',')) {
29
+ const m = /^\s*(\d+)(?:\s*-\s*(\d+))?\s*$/.exec(part);
30
+ if (!m)
31
+ throw new RafError(`ref : --sections invalide : « ${part.trim()} » (attendu : 3, 3-5 ou 3-5,9)`);
32
+ const from = Number(m[1]);
33
+ const to = m[2] === undefined ? from : Number(m[2]);
34
+ if (from < 1 || to < from)
35
+ throw new RafError(`ref : --sections invalide : « ${part.trim()} » (pages de 1 à ${pages}, début avant fin)`);
36
+ if (to > pages)
37
+ throw new RafError(`ref : --sections : page ${to} demandée, le document a ${pages} pages`);
38
+ for (let n = from; n <= to; n++)
39
+ out.add(n);
40
+ }
41
+ return [...out].sort((a, b) => a - b);
42
+ }
43
+ const slugOf = (file) => basename(file, extname(file)).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
44
+ export const SLUG = /^[a-z0-9][a-z0-9-]*$/;
45
+ /** `cadence ref add` : enregistre un PDF, rend les pages visées, garde le texte positionné et les images, construit l'index des figures. */
46
+ export function add(o) {
47
+ // Les outils d'abord : sans eux, rien n'est lu ni écrit.
48
+ const tools = requireTools(o.env);
49
+ const file = resolve(o.cwd, o.document);
50
+ if (!existsSync(file) || !statSync(file).isFile())
51
+ throw new RafError(`ref : document introuvable : ${file}`);
52
+ const slug = o.slug ?? slugOf(file);
53
+ if (!SLUG.test(slug))
54
+ throw new RafError(`ref : slug invalide : « ${slug} » (lettres minuscules, chiffres et tirets ; --slug pour le choisir)`);
55
+ const total = pageCount(tools, file);
56
+ const wanted = o.sections === undefined ? (total <= WHOLE_DOCUMENT_MAX ? Array.from({ length: total }, (_, i) => i + 1) : []) : parseSections(o.sections, total);
57
+ const sha256 = createHash('sha256').update(readFileSync(file)).digest('hex');
58
+ const docDir = join(o.refsDir(), slug);
59
+ const before = readDocument(docDir);
60
+ if (before && before.sha256 !== sha256) {
61
+ throw new RafError(`ref : le slug « ${slug} » est déjà pris par un autre document (${before.source}, empreinte ${before.sha256.slice(0, 12)}) : choisir un autre nom avec --slug`);
62
+ }
63
+ const text = textLayout(tools, file);
64
+ const listed = listImages(tools, file);
65
+ const index = indexText(text, new Set(listed.map((i) => i.page)));
66
+ // pages à rendre : celles visées, celles déjà rendues par un appel précédent du même document, et celles des figures qu'elles citent
67
+ const keep = new Set(before?.pages.filter((p) => p.rendered).map((p) => p.n));
68
+ const visees = new Set([...wanted, ...keep]);
69
+ const rendered = new Set([...visees, ...citedPages(index, visees)]);
70
+ const ordered = [...rendered].sort((a, b) => a - b);
71
+ renderPages(tools, file, ordered, join(docDir, 'pages'));
72
+ renderThumbnails(tools, file, total, join(docDir, 'thumbs'));
73
+ const images = [];
74
+ const placed = [];
75
+ const regions = new Map();
76
+ for (const n of ordered) {
77
+ if (!listed.some((i) => i.page === n))
78
+ continue;
79
+ extractImages(tools, file, n, join(docDir, 'images'));
80
+ regions.set(n, imageRegions(tools, file, n));
81
+ }
82
+ for (const img of listed) {
83
+ const box = regions.get(img.page)?.[img.rank - 1];
84
+ const stored = box && rendered.has(img.page) ? { page: img.page, file: `images/${imageFile(img.page, img.slot)}`, x: box.x, y: box.y, l: box.l, h: box.h } : { page: img.page, file: null, x: null, y: null, l: null, h: null };
85
+ images.push(stored);
86
+ placed.push({ page: img.page, box: stored.x === null ? null : { x: stored.x, y: stored.y, l: stored.l, h: stored.h } });
87
+ }
88
+ attachRegions(index, placed);
89
+ const pages = text.map((p) => ({ ...p, rendered: rendered.has(p.n) }));
90
+ const doc = {
91
+ slug,
92
+ source: file,
93
+ sha256,
94
+ added: before?.added ?? o.now.toISOString(),
95
+ dpi: DPI,
96
+ sections: o.sections ?? before?.sections ?? null,
97
+ tools,
98
+ pages,
99
+ images,
100
+ figures: index.figures,
101
+ unresolved: index.unresolved,
102
+ };
103
+ writeDocument(docDir, doc);
104
+ o.out(`${slug} : ${basename(file)}`);
105
+ o.out(`pages : ${total}`);
106
+ const sansTexte = text.filter((p) => p.lines.reduce((n, l) => n + l.text.trim().length, 0) < SCANNED_MAX_CHARS).map((p) => p.n);
107
+ if (sansTexte.length > 0)
108
+ o.out(`pages sans texte : ${sansTexte.length} (${ranges(sansTexte)}) — scannées ou faites d'images : l'index n'en dit rien, parcourir ${join(docDir, 'thumbs')} pour choisir les pages à rendre (--sections)`);
109
+ o.out(`rendues : ${ordered.length}${ordered.length > 0 ? ` (${ordered.join(', ')})` : ''}`);
110
+ o.out(`images : ${images.length}`);
111
+ o.out(`figures : ${index.figures.length}`);
112
+ o.out(`liens non résolus : ${index.unresolved.length}`);
113
+ o.out(`dossier : ${docDir}`);
114
+ if (ordered.length === 0)
115
+ o.out(`aucune page rendue (grand document : seul l'index est construit) — cadence ref add ${o.document} --slug ${slug} --sections 3-5 pour rendre des pages`);
116
+ }