@nodefony/framework 10.0.0-alpha.1

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 (96) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +50 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/index.js +211 -0
  6. package/dist/nodefony/config/config.js +61 -0
  7. package/dist/nodefony/config/defineModuleConfig.js +36 -0
  8. package/dist/nodefony/controller/AdminApiController.js +163 -0
  9. package/dist/nodefony/controller/ApiKeyController.js +151 -0
  10. package/dist/nodefony/controller/BenchController.js +132 -0
  11. package/dist/nodefony/controller/IssuerMetadataController.js +148 -0
  12. package/dist/nodefony/controller/OAuth2Controller.js +133 -0
  13. package/dist/nodefony/controller/ProtectedResourceMetadataController.js +221 -0
  14. package/dist/nodefony/controller/SessionAuthController.js +141 -0
  15. package/dist/nodefony/controller/TokenAuthController.js +113 -0
  16. package/dist/nodefony/controller/TotpController.js +129 -0
  17. package/dist/nodefony/controller/WebAuthnController.js +242 -0
  18. package/dist/nodefony/controller/oauthAuthority.js +74 -0
  19. package/dist/nodefony/decorators/routerDecorators.js +967 -0
  20. package/dist/nodefony/interfaces/IAdminBroker.js +1 -0
  21. package/dist/nodefony/interfaces/IController.js +1 -0
  22. package/dist/nodefony/interfaces/IIdempotencyStore.js +1 -0
  23. package/dist/nodefony/interfaces/IResolver.js +1 -0
  24. package/dist/nodefony/interfaces/IRoute.js +1 -0
  25. package/dist/nodefony/interfaces/index.js +1 -0
  26. package/dist/nodefony/service/AdminBroker.js +106 -0
  27. package/dist/nodefony/service/Eta.js +68 -0
  28. package/dist/nodefony/service/IdempotencyStore.js +136 -0
  29. package/dist/nodefony/service/router.js +243 -0
  30. package/dist/nodefony/src/Controller.js +515 -0
  31. package/dist/nodefony/src/FrameworkAdminApi.js +268 -0
  32. package/dist/nodefony/src/KernelAdminApi.js +1243 -0
  33. package/dist/nodefony/src/PlaygroundAdminApi.js +97 -0
  34. package/dist/nodefony/src/RedisIdempotencyStore.js +254 -0
  35. package/dist/nodefony/src/Resolver.js +416 -0
  36. package/dist/nodefony/src/ResourceController.js +148 -0
  37. package/dist/nodefony/src/Route.js +476 -0
  38. package/dist/nodefony/src/SyslogAdminApi.js +466 -0
  39. package/dist/nodefony/src/Template.js +15 -0
  40. package/dist/nodefony/src/configMutation.js +186 -0
  41. package/dist/nodefony/src/docsReader.js +929 -0
  42. package/dist/nodefony/src/idempotency.js +137 -0
  43. package/dist/nodefony/src/idempotencyGc.js +32 -0
  44. package/dist/nodefony/src/idempotencyStoreRegistry.js +36 -0
  45. package/dist/nodefony/src/scopeCatalog.js +40 -0
  46. package/dist/nodefony/src/syslogFilters.js +51 -0
  47. package/dist/types/index.d.ts +96 -0
  48. package/dist/types/nodefony/config/config.d.ts +41 -0
  49. package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
  50. package/dist/types/nodefony/controller/AdminApiController.d.ts +70 -0
  51. package/dist/types/nodefony/controller/ApiKeyController.d.ts +49 -0
  52. package/dist/types/nodefony/controller/BenchController.d.ts +45 -0
  53. package/dist/types/nodefony/controller/IssuerMetadataController.d.ts +86 -0
  54. package/dist/types/nodefony/controller/OAuth2Controller.d.ts +81 -0
  55. package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +147 -0
  56. package/dist/types/nodefony/controller/SessionAuthController.d.ts +74 -0
  57. package/dist/types/nodefony/controller/TokenAuthController.d.ts +56 -0
  58. package/dist/types/nodefony/controller/TotpController.d.ts +42 -0
  59. package/dist/types/nodefony/controller/WebAuthnController.d.ts +123 -0
  60. package/dist/types/nodefony/controller/oauthAuthority.d.ts +54 -0
  61. package/dist/types/nodefony/decorators/routerDecorators.d.ts +632 -0
  62. package/dist/types/nodefony/interfaces/IAdminBroker.d.ts +79 -0
  63. package/dist/types/nodefony/interfaces/IController.d.ts +38 -0
  64. package/dist/types/nodefony/interfaces/IIdempotencyStore.d.ts +1 -0
  65. package/dist/types/nodefony/interfaces/IResolver.d.ts +25 -0
  66. package/dist/types/nodefony/interfaces/IRoute.d.ts +31 -0
  67. package/dist/types/nodefony/interfaces/index.d.ts +5 -0
  68. package/dist/types/nodefony/service/AdminBroker.d.ts +37 -0
  69. package/dist/types/nodefony/service/Eta.d.ts +25 -0
  70. package/dist/types/nodefony/service/IdempotencyStore.d.ts +45 -0
  71. package/dist/types/nodefony/service/router.d.ts +53 -0
  72. package/dist/types/nodefony/src/Controller.d.ts +193 -0
  73. package/dist/types/nodefony/src/FrameworkAdminApi.d.ts +35 -0
  74. package/dist/types/nodefony/src/KernelAdminApi.d.ts +71 -0
  75. package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +98 -0
  76. package/dist/types/nodefony/src/RedisIdempotencyStore.d.ts +104 -0
  77. package/dist/types/nodefony/src/Resolver.d.ts +165 -0
  78. package/dist/types/nodefony/src/ResourceController.d.ts +171 -0
  79. package/dist/types/nodefony/src/Route.d.ts +192 -0
  80. package/dist/types/nodefony/src/SyslogAdminApi.d.ts +38 -0
  81. package/dist/types/nodefony/src/Template.d.ts +8 -0
  82. package/dist/types/nodefony/src/configMutation.d.ts +109 -0
  83. package/dist/types/nodefony/src/docsReader.d.ts +369 -0
  84. package/dist/types/nodefony/src/idempotency.d.ts +96 -0
  85. package/dist/types/nodefony/src/idempotencyGc.d.ts +30 -0
  86. package/dist/types/nodefony/src/idempotencyStoreRegistry.d.ts +59 -0
  87. package/dist/types/nodefony/src/scopeCatalog.d.ts +26 -0
  88. package/dist/types/nodefony/src/syslogFilters.d.ts +52 -0
  89. package/docs/admin.md +451 -0
  90. package/docs/controller.md +645 -0
  91. package/docs/decorateurs.md +845 -0
  92. package/docs/idempotence.md +741 -0
  93. package/docs/index.md +151 -0
  94. package/docs/routing.md +648 -0
  95. package/docs/templates.md +380 -0
  96. package/package.json +83 -0
@@ -0,0 +1,929 @@
1
+ import { basename, dirname, extname, join } from "node:path";
2
+ import { findProjectRoot, resolveSymbolsFile } from "nodefony";
3
+ import { existsSync } from "node:fs";
4
+ import { promisify } from "node:util";
5
+ import { readFile, readdir, stat } from "node:fs/promises";
6
+ import { fileURLToPath } from "node:url";
7
+ import { execFile, spawn } from "node:child_process";
8
+ //#region nodefony/src/docsReader.ts
9
+ const execFileAsync = promisify(execFile);
10
+ /**
11
+ * Racine de l'APPLICATION servie — jamais le dossier courant du process.
12
+ *
13
+ * Tout ce que ce module lit hors des modules eux-mêmes (`.ai/symbols.json`, les
14
+ * `node_modules` hissés, les docs du core, le dépôt git) vit à la racine de
15
+ * l'application. Or `process.cwd()` est le dossier depuis lequel quelqu'un a
16
+ * TAPÉ la commande : lancer l'application depuis un sous-dossier suffisait à
17
+ * vider les onglets Docs et API du plan d'administration, sans erreur ni trace
18
+ * — un fichier absent est indistinguable d'un module sans documentation.
19
+ *
20
+ * On remonte donc au premier dossier portant `nodefony.config.ts`, avec la même
21
+ * définition de « où commence l'app » que le lanceur, les scaffolds et
22
+ * `nodefony doctor`. Hors projet (dépôt de paquets, test), le dossier courant
23
+ * reste le repli.
24
+ */
25
+ function appRoot() {
26
+ return findProjectRoot(process.cwd()) ?? process.cwd();
27
+ }
28
+ /** Slug url-safe — borne le path traversal sur `module/{name}/docs/{slug}`. */
29
+ const SLUG_RE = /^[a-z0-9][a-z0-9._-]*$/i;
30
+ /**
31
+ * Parse un bloc frontmatter YAML minimaliste (clé: valeur scalaires).
32
+ *
33
+ * Volontairement sans dépendance (`gray-matter` etc.) : on ne gère que des
34
+ * scalaires `key: value` entre deux `---`. Les listes inline (`[a, b]`) sont
35
+ * conservées en string brute. Suffisant pour `title/module/status/since/
36
+ * updated/order`. Renvoie `{ data, body }` ; sans fence → `data` vide.
37
+ */
38
+ function parseFrontmatter(raw) {
39
+ if (!raw.startsWith("---")) return {
40
+ data: {},
41
+ body: raw
42
+ };
43
+ const end = raw.indexOf("\n---", 3);
44
+ if (end === -1) return {
45
+ data: {},
46
+ body: raw
47
+ };
48
+ const block = raw.slice(3, end);
49
+ const after = raw.slice(end + 4);
50
+ const body = after.startsWith("\n") ? after.slice(1) : after;
51
+ const data = {};
52
+ for (const line of block.split("\n")) {
53
+ const trimmed = line.trim();
54
+ if (!trimmed || trimmed.startsWith("#")) continue;
55
+ const colon = trimmed.indexOf(":");
56
+ if (colon === -1) continue;
57
+ const key = trimmed.slice(0, colon).trim();
58
+ let value = trimmed.slice(colon + 1).trim();
59
+ if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
60
+ if (key === "order") {
61
+ const n = Number(value);
62
+ data.order = Number.isFinite(n) ? n : void 0;
63
+ } else if (key === "last-updated" && data.updated === void 0) data.updated = value;
64
+ else data[key] = value;
65
+ }
66
+ return {
67
+ data,
68
+ body
69
+ };
70
+ }
71
+ /** Premier titre `# H1` du corps markdown, ou `null`. */
72
+ function firstHeading(body) {
73
+ const m = body.match(/^#\s+(.+)$/m);
74
+ return m ? m[1].trim() : null;
75
+ }
76
+ /**
77
+ * Date ISO du dernier commit git d'un fichier (détecte la dérive doc↔code).
78
+ *
79
+ * Fallback sur le `mtime` fs si le fichier n'est pas suivi par git ou hors
80
+ * dépôt. Endpoint admin (basse fréquence) → coût d'un `spawn` git acceptable.
81
+ */
82
+ async function gitLastUpdated(file) {
83
+ try {
84
+ const { stdout } = await execFileAsync("git", [
85
+ "log",
86
+ "-1",
87
+ "--format=%cI",
88
+ "--",
89
+ file
90
+ ], { cwd: appRoot() });
91
+ const iso = stdout.trim();
92
+ if (iso) return iso;
93
+ } catch {}
94
+ try {
95
+ return (await stat(file)).mtime.toISOString();
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+ /** Construit un `DocSummary` à partir du contenu brut d'un `.md`. */
101
+ async function summarize(docsDir, fileName, withGit) {
102
+ const slug = basename(fileName, extname(fileName));
103
+ const full = join(docsDir, fileName);
104
+ const { data, body } = parseFrontmatter(await readFile(full, "utf8"));
105
+ const title = docTitle(data, body, slug);
106
+ return {
107
+ slug,
108
+ title,
109
+ navTitle: typeof data.navTitle === "string" && data.navTitle.trim() ? data.navTitle.trim() : title,
110
+ status: typeof data.status === "string" ? data.status : null,
111
+ since: typeof data.since === "string" ? data.since : null,
112
+ updated: typeof data.updated === "string" ? data.updated : null,
113
+ gitUpdated: withGit ? await gitLastUpdated(full) : null,
114
+ order: typeof data.order === "number" ? data.order : slug === "index" ? 0 : 100
115
+ };
116
+ }
117
+ /**
118
+ * Sommaire des docs d'un module : lit les `*.md` de `<modulePath>/docs/`
119
+ * (un niveau, non récursif), parse le frontmatter, trie par `order` puis slug.
120
+ *
121
+ * @param modulePath - chemin disque du module (`Module.path`).
122
+ * @returns liste triée (vide si le dossier `docs/` est absent).
123
+ */
124
+ async function listModuleDocs(modulePath, withGit = false) {
125
+ const docsDir = join(modulePath, "docs");
126
+ let entries;
127
+ try {
128
+ entries = await readdir(docsDir);
129
+ } catch {
130
+ return [];
131
+ }
132
+ const mdFiles = entries.filter((f) => f.toLowerCase().endsWith(".md"));
133
+ return (await Promise.all(mdFiles.map((f) => summarize(docsDir, f, withGit)))).sort((a, b) => a.order - b.order || a.slug.localeCompare(b.slug));
134
+ }
135
+ /**
136
+ * Comptage rapide des docs (`*.md`) d'un module — readdir seul, sans lire/parser
137
+ * ni `git`. Pour les KPI/overview (`docsCount`) où seul le nombre importe.
138
+ *
139
+ * @param modulePath - chemin disque du module (`Module.path`).
140
+ * @returns nombre de fichiers `.md` dans `<modulePath>/docs/` (0 si absent).
141
+ */
142
+ async function countModuleDocs(modulePath) {
143
+ try {
144
+ return (await readdir(join(modulePath, "docs"))).filter((f) => f.toLowerCase().endsWith(".md")).length;
145
+ } catch {
146
+ return 0;
147
+ }
148
+ }
149
+ /**
150
+ * Lit une doc module par slug : frontmatter + corps markdown brut.
151
+ *
152
+ * Le slug est borné (`SLUG_RE`) avant toute jointure de chemin → pas de path
153
+ * traversal vers l'extérieur de `<modulePath>/docs/`.
154
+ *
155
+ * @returns `null` si slug invalide ou fichier absent.
156
+ */
157
+ async function readModuleDoc(modulePath, slug) {
158
+ if (!SLUG_RE.test(slug)) return null;
159
+ const full = join(modulePath, "docs", `${slug}.md`);
160
+ let raw;
161
+ try {
162
+ raw = await readFile(full, "utf8");
163
+ } catch {
164
+ return null;
165
+ }
166
+ const { data, body } = parseFrontmatter(raw);
167
+ return {
168
+ slug,
169
+ frontmatter: data,
170
+ markdown: body,
171
+ gitUpdated: await gitLastUpdated(full)
172
+ };
173
+ }
174
+ /**
175
+ * Titre humain d'une doc — frontmatter, sinon premier `# H1`, sinon le slug.
176
+ *
177
+ * Partagé par le sommaire et la recherche : deux lectures du même fichier qui
178
+ * n'en tireraient pas le même titre feraient croire à deux documents.
179
+ */
180
+ function docTitle(data, body, slug) {
181
+ return data.title ?? firstHeading(body) ?? (typeof data.topic === "string" ? data.topic : null) ?? slug;
182
+ }
183
+ /** Docs rendues par défaut — au-delà, l'agent relit au lieu de décider. */
184
+ const SEARCH_MAX_DOCS = 20;
185
+ /** Extraits gardés par doc. */
186
+ const SEARCH_MAX_PER_DOC = 3;
187
+ /** Largeur d'un extrait : une ligne de markdown peut faire un paragraphe. */
188
+ const SNIPPET_MAX_CHARS = 240;
189
+ /** Ce que pèse un terme trouvé dans le titre ou le slug, en occurrences. */
190
+ const TITLE_WEIGHT = 5;
191
+ /**
192
+ * Longueur d'une page de référence, en caractères — le pivot de la DENSITÉ.
193
+ *
194
+ * ⚠️ Compter les occurrences BRUTES classe par la taille, pas par la
195
+ * pertinence : un tableau de bord de plusieurs dizaines de milliers de
196
+ * caractères mentionne tout, donc il gagne toutes les recherches — et sortait
197
+ * devant la page qui TRAITE le sujet demandé. Ce qui distingue un document
198
+ * pertinent d'un document long, c'est la densité du terme, pas son total.
199
+ *
200
+ * Un document plus court que ce pivot n'est jamais AVANTAGÉ (le diviseur est
201
+ * planché à 1) : on corrige le biais du volume, on n'en crée pas l'inverse.
202
+ */
203
+ const DOC_LENGTH_PIVOT = 8e3;
204
+ /**
205
+ * Forme comparable d'un texte : minuscules, sans diacritiques.
206
+ *
207
+ * Un agent tape `securite` et la doc écrit « sécurité » — sans repli, la
208
+ * recherche rend zéro résultat sur un corpus qui traite le sujet en quinze
209
+ * pages, et rien ne dit que c'est l'accent qui a manqué.
210
+ */
211
+ function fold(text) {
212
+ return text.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
213
+ }
214
+ /**
215
+ * Fenêtre lisible autour de la première occurrence d'un terme dans une ligne.
216
+ *
217
+ * Une ligne de markdown n'est pas une ligne d'écran : un paragraphe entier peut
218
+ * tenir sur une seule, et le rendre en entier fait de la recherche un second
219
+ * déversement du corpus.
220
+ */
221
+ function snippet(line, folded, term) {
222
+ if (line.length <= SNIPPET_MAX_CHARS) return line.trim();
223
+ const at = folded.indexOf(term);
224
+ const start = Math.max(0, at - Math.floor(SNIPPET_MAX_CHARS / 3));
225
+ const end = Math.min(line.length, start + SNIPPET_MAX_CHARS);
226
+ return (start > 0 ? "…" : "") + line.slice(start, end).trim() + (end < line.length ? "…" : "");
227
+ }
228
+ /**
229
+ * Cherche un texte dans les docs colocalisées des modules donnés.
230
+ *
231
+ * ⭐ **Pourquoi cette fonction existe** : chez un utilisateur, la documentation
232
+ * des modules vit sous `node_modules/@nodefony/<mod>/docs/` — un dossier que `git`
233
+ * ignore, donc que `rg` et les outils de recherche des agents EXCLUENT par
234
+ * défaut. La doc est livrée et introuvable ; c'est cette porte qui la rend
235
+ * atteignable, sans quoi l'agent réécrit à la main ce qui est déjà écrit.
236
+ *
237
+ * Un document est retenu s'il porte **TOUS** les termes (et non l'un d'eux) :
238
+ * sur un corpus où « session » apparaît partout, un OU rendrait le corpus.
239
+ * La comparaison est faite sans casse ni diacritiques ({@link fold}).
240
+ *
241
+ * Aucun index n'est conservé : le corpus est relu à chaque appel. C'est une
242
+ * opération de développement, rare et explicite — un index en mémoire coûterait
243
+ * en permanence ce qu'il ferait gagner quelques fois, et se périmerait à la
244
+ * première doc éditée.
245
+ *
246
+ * @param targets - modules à balayer (clé + chemin disque)
247
+ * @param query - texte cherché ; les espaces séparent des termes cumulatifs
248
+ * @param options - bornes de rendu (`limit` docs, `perDoc` extraits)
249
+ * @returns les docs retenues, et ce que la recherche a réellement balayé
250
+ */
251
+ async function searchModuleDocs(targets, query, options = {}) {
252
+ const terms = fold(query).split(/\s+/).filter((t) => t.length > 0);
253
+ const limit = options.limit ?? SEARCH_MAX_DOCS;
254
+ const perDoc = options.perDoc ?? SEARCH_MAX_PER_DOC;
255
+ if (terms.length === 0) return {
256
+ terms,
257
+ scanned: 0,
258
+ matched: 0,
259
+ hits: []
260
+ };
261
+ const hits = [];
262
+ let scanned = 0;
263
+ for (const target of targets) {
264
+ const docsDir = join(target.path, "docs");
265
+ let entries;
266
+ try {
267
+ entries = await readdir(docsDir);
268
+ } catch {
269
+ continue;
270
+ }
271
+ for (const fileName of entries) {
272
+ if (!fileName.toLowerCase().endsWith(".md")) continue;
273
+ let raw;
274
+ try {
275
+ raw = await readFile(join(docsDir, fileName), "utf8");
276
+ } catch {
277
+ continue;
278
+ }
279
+ scanned += 1;
280
+ const slug = basename(fileName, extname(fileName));
281
+ const { data, body } = parseFrontmatter(raw);
282
+ const title = docTitle(data, body, slug);
283
+ const foldedTitle = fold(`${title} ${typeof data.navTitle === "string" && data.navTitle.trim() ? data.navTitle.trim() : ""} ${slug}`);
284
+ const lines = body.split("\n");
285
+ const foldedLines = lines.map(fold);
286
+ const foldedBody = fold(body);
287
+ if (!terms.every((term) => foldedBody.includes(term) || foldedTitle.includes(term))) continue;
288
+ let occurrences = 0;
289
+ let score = 0;
290
+ for (const term of terms) {
291
+ let at = foldedBody.indexOf(term);
292
+ while (at !== -1) {
293
+ occurrences += 1;
294
+ at = foldedBody.indexOf(term, at + term.length);
295
+ }
296
+ if (foldedTitle.includes(term)) score += TITLE_WEIGHT;
297
+ }
298
+ score += occurrences / Math.max(1, foldedBody.length / DOC_LENGTH_PIVOT);
299
+ const candidates = [];
300
+ for (let i = 0; i < lines.length; i += 1) {
301
+ const carried = terms.filter((t) => foldedLines[i].includes(t));
302
+ if (carried.length === 0) continue;
303
+ candidates.push({
304
+ lineNumber: i,
305
+ coverage: carried.length,
306
+ matchedTerm: carried[0]
307
+ });
308
+ }
309
+ candidates.sort((a, b) => b.coverage - a.coverage || a.lineNumber - b.lineNumber);
310
+ const matches = candidates.slice(0, perDoc).sort((a, b) => a.lineNumber - b.lineNumber).map((c) => ({
311
+ line: c.lineNumber + 1,
312
+ text: snippet(lines[c.lineNumber], foldedLines[c.lineNumber], c.matchedTerm)
313
+ }));
314
+ hits.push({
315
+ module: target.key,
316
+ slug,
317
+ title,
318
+ matches,
319
+ occurrences,
320
+ score
321
+ });
322
+ }
323
+ }
324
+ hits.sort((a, b) => b.score - a.score || a.slug.localeCompare(b.slug));
325
+ const returned = hits.slice(0, limit);
326
+ return {
327
+ terms,
328
+ scanned,
329
+ matched: hits.length,
330
+ hits: returned,
331
+ ...hits.length > returned.length ? { note: `${hits.length} documents portent ces termes, ${returned.length} sont rendus (borne « limit » = ${limit}). Précise les termes, ou rappelle avec limit plus grand.` } : {}
332
+ };
333
+ }
334
+ /**
335
+ * Symboles TS exportés d'un module + descriptions TSDoc, lus depuis
336
+ * `.ai/symbols.json` (à la racine de l'application, cf {@link appRoot}).
337
+ *
338
+ * 100 % auto-généré (jamais de `.d.ts` manuel) → zéro divergence avec le code.
339
+ * Tab API maigre tant que la couverture TSDoc est faible : c'est volontaire,
340
+ * ça pousse à documenter.
341
+ *
342
+ * @param packageName - nom npm du module (`Module.getModuleName()`, ex
343
+ * `"@nodefony/http"`) — clé `.module` dans le graphe symbolique.
344
+ * @returns symboles exportés triés par kind puis nom (vide si fichier absent).
345
+ */
346
+ async function listModuleSymbols(packageName) {
347
+ const file = resolveSymbolsFile(process.cwd());
348
+ if (file === null) return [];
349
+ let parsed;
350
+ try {
351
+ parsed = JSON.parse(await readFile(file, "utf8"));
352
+ } catch {
353
+ return [];
354
+ }
355
+ const symbols = parsed.symbols ?? {};
356
+ const out = [];
357
+ for (const sym of Object.values(symbols)) {
358
+ if (sym.module !== packageName || sym.exported !== true) continue;
359
+ out.push({
360
+ name: sym.name ?? "",
361
+ kind: sym.kind ?? "unknown",
362
+ file: sym.file ?? "",
363
+ description: typeof sym.description === "string" ? sym.description : null,
364
+ extends: sym.extends ?? null,
365
+ implements: Array.isArray(sym.implements) ? sym.implements : [],
366
+ decorators: Array.isArray(sym.decorators) ? sym.decorators : []
367
+ });
368
+ }
369
+ return out.sort((a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name));
370
+ }
371
+ /** Au-delà, une déclaration cesse d'informer et se met à remplir. */
372
+ const DECLARATION_MAX_CHARS = 12e3;
373
+ /** Garde-fou de balayage : un `dist/types` sain tient en quelques dizaines. */
374
+ const DECLARATION_MAX_FILES = 400;
375
+ /**
376
+ * Fichiers de types d'un module, du plus probable au moins probable.
377
+ *
378
+ * `dist/types/` est ce qu'un utilisateur REÇOIT ; les sources `.ts` ne sont là
379
+ * que dans ce dépôt, où quelques paquets pointent leurs types vers leur
380
+ * `index.ts` (anti-race de build). On regarde donc les deux, dans cet ordre —
381
+ * mais on ne descend jamais dans `node_modules`, dont les types appartiennent à
382
+ * d'autres.
383
+ */
384
+ async function typeFiles(modulePath) {
385
+ const found = [];
386
+ for (const sub of [
387
+ "dist/types",
388
+ "nodefony",
389
+ "src"
390
+ ]) {
391
+ let entries;
392
+ try {
393
+ entries = await readdir(join(modulePath, sub), { recursive: true });
394
+ } catch {
395
+ continue;
396
+ }
397
+ for (const entry of entries) {
398
+ if (entry.includes("node_modules")) continue;
399
+ if (!entry.endsWith(".d.ts") && !entry.endsWith(".ts")) continue;
400
+ if (entry.endsWith(".test.ts")) continue;
401
+ found.push(join(sub, entry));
402
+ if (found.length >= DECLARATION_MAX_FILES) return found;
403
+ }
404
+ }
405
+ return found;
406
+ }
407
+ /**
408
+ * Où commence la déclaration d'un symbole — avec le TSDoc qui la précède.
409
+ *
410
+ * Le commentaire n'est pas un ornement : c'est là que vit le POURQUOI, et il
411
+ * traverse le build (`.d.ts`) là où un `//` inline disparaît. Le rendre avec la
412
+ * signature est ce qui distingue « voici les paramètres » de « voici ce qu'en
413
+ * faire ».
414
+ */
415
+ function declarationStart(lines, at) {
416
+ let start = at;
417
+ if (start > 0 && lines[start - 1].trim().endsWith("*/")) {
418
+ let i = start - 1;
419
+ while (i > 0 && !lines[i].trim().startsWith("/**")) i -= 1;
420
+ if (lines[i].trim().startsWith("/**")) start = i;
421
+ }
422
+ return start;
423
+ }
424
+ /**
425
+ * Extrait la déclaration d'un symbole des types LIVRÉS d'un module.
426
+ *
427
+ * ⭐ **Pourquoi cette fonction existe** : le graphe symbolique
428
+ * (`.ai/symbols.json`) dit qu'un symbole existe, ce qu'il étend et la première
429
+ * phrase de sa documentation — mais **pas sa signature**. À « quels arguments
430
+ * prend cette méthode ? », un agent n'a donc aucune réponse : les `.d.ts` qui
431
+ * la portent vivent sous `node_modules`, que git ignore et que les outils de
432
+ * recherche de fichiers excluent. Il devine, et il devine faux.
433
+ *
434
+ * Le nom demandé ne touche JAMAIS un chemin : il sert à filtrer du texte. Les
435
+ * fichiers balayés sont dérivés du module, pas de l'appelant — un paramètre qui
436
+ * entrerait dans un `join` serait une traversée de répertoire offerte.
437
+ *
438
+ * @param modulePath - chemin disque du module (`Module.path`)
439
+ * @param symbolName - nom exact du symbole (`AbstractCrudService`, `IKernel`)
440
+ * @returns la déclaration et son fichier, ou `null` si rien ne la porte
441
+ */
442
+ async function readSymbolDeclaration(modulePath, symbolName) {
443
+ if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(symbolName)) return null;
444
+ const declare = new RegExp(String.raw`^\s*(?:export\s+)?(?:declare\s+)?(?:abstract\s+)?` + String.raw`(?:class|interface|function|const|let|var|type|enum|namespace)\s+` + symbolName + String.raw`\b`);
445
+ for (const relative of await typeFiles(modulePath)) {
446
+ let raw;
447
+ try {
448
+ raw = await readFile(join(modulePath, relative), "utf8");
449
+ } catch {
450
+ continue;
451
+ }
452
+ if (!raw.includes(symbolName)) continue;
453
+ const lines = raw.split("\n");
454
+ const at = lines.findIndex((line) => declare.test(line));
455
+ if (at === -1) continue;
456
+ const start = declarationStart(lines, at);
457
+ let depth = 0;
458
+ let opened = false;
459
+ let end = at;
460
+ for (let i = at; i < lines.length; i += 1) {
461
+ for (const ch of lines[i]) if (ch === "{") {
462
+ depth += 1;
463
+ opened = true;
464
+ } else if (ch === "}") depth -= 1;
465
+ end = i;
466
+ if (opened && depth <= 0) break;
467
+ if (!opened && lines[i].trimEnd().endsWith(";")) break;
468
+ }
469
+ const declaration = lines.slice(start, end + 1).join("\n");
470
+ return declaration.length > DECLARATION_MAX_CHARS ? {
471
+ declarationFile: relative,
472
+ declaration: declaration.slice(0, DECLARATION_MAX_CHARS),
473
+ truncated: true
474
+ } : {
475
+ declarationFile: relative,
476
+ declaration,
477
+ truncated: false
478
+ };
479
+ }
480
+ return null;
481
+ }
482
+ /**
483
+ * Identité du package npm du core (`@nodefony/core`) tel qu'indexé dans
484
+ * `.ai/symbols.json` — le core est référencé sous ce nom logique, **pas** sous
485
+ * son nom npm réel (`nodefony`, héritage JS).
486
+ */
487
+ const CORE_PACKAGE = "@nodefony/core";
488
+ /**
489
+ * Dépendances d'un module : depuis son `package.json` (dependencies +
490
+ * peerDependencies) avec la version RÉELLEMENT installée (lue dans
491
+ * `node_modules/<dep>/package.json`, local puis hoisté à la racine).
492
+ */
493
+ async function readDependencies(modulePath) {
494
+ let pkg = {};
495
+ try {
496
+ pkg = JSON.parse(await readFile(join(modulePath, "package.json"), "utf8"));
497
+ } catch {}
498
+ const ranges = {
499
+ ...pkg.dependencies,
500
+ ...pkg.peerDependencies
501
+ };
502
+ const root = appRoot();
503
+ const out = [];
504
+ for (const name of Object.keys(ranges).sort()) {
505
+ const kind = name === "nodefony" || name.startsWith("@nodefony/") ? "nodefony" : "external";
506
+ let installed = null;
507
+ for (const base of [modulePath, root]) try {
508
+ const dp = JSON.parse(await readFile(join(base, "node_modules", name, "package.json"), "utf8"));
509
+ if (typeof dp.version === "string") {
510
+ installed = dp.version;
511
+ break;
512
+ }
513
+ } catch {}
514
+ out.push({
515
+ name,
516
+ kind,
517
+ range: ranges[name] ?? null,
518
+ installed
519
+ });
520
+ }
521
+ return out;
522
+ }
523
+ /** Compare deux versions semver (a > b ?) — major.minor.patch numérique. */
524
+ function semverGt(a, b) {
525
+ const p = (s) => s.replace(/^[^\d]*/, "").split(".").map((n) => parseInt(n, 10) || 0);
526
+ const pa = p(a);
527
+ const pb = p(b);
528
+ for (let i = 0; i < 3; i++) {
529
+ if ((pa[i] || 0) > (pb[i] || 0)) return true;
530
+ if ((pa[i] || 0) < (pb[i] || 0)) return false;
531
+ }
532
+ return false;
533
+ }
534
+ /**
535
+ * Vérifie les MAJ des deps EXTERNES via le registry npm (`/<pkg>/latest`).
536
+ * Les deps Nodefony (workspaces locaux) sont ignorées. Réseau → on-demand.
537
+ */
538
+ async function checkOutdated(deps) {
539
+ const external = deps.filter((d) => d.kind === "external");
540
+ return Promise.all(external.map(async (d) => {
541
+ let latest = null;
542
+ try {
543
+ const url = `https://registry.npmjs.org/${encodeURIComponent(d.name)}/latest`;
544
+ const r = await fetch(url, { signal: AbortSignal.timeout(8e3) });
545
+ if (r.ok) {
546
+ const j = await r.json();
547
+ if (typeof j.version === "string") latest = j.version;
548
+ }
549
+ } catch {}
550
+ return {
551
+ name: d.name,
552
+ installed: d.installed,
553
+ latest,
554
+ outdated: Boolean(latest && d.installed && semverGt(latest, d.installed))
555
+ };
556
+ }));
557
+ }
558
+ /** Walk `*.test.ts` (hors node_modules/dist/.coverage) → chemins relatifs triés. */
559
+ async function collectTestFiles(modulePath) {
560
+ const out = [];
561
+ const walk = async (dir, depth) => {
562
+ if (depth > 8) return;
563
+ let entries;
564
+ try {
565
+ entries = await readdir(dir, { withFileTypes: true });
566
+ } catch {
567
+ return;
568
+ }
569
+ for (const e of entries) {
570
+ if (e.name === "node_modules" || e.name === "dist" || e.name === ".coverage") continue;
571
+ const full = join(dir, e.name);
572
+ if (e.isDirectory()) await walk(full, depth + 1);
573
+ else if (e.isFile() && e.name.endsWith(".test.ts")) out.push(rel(full, modulePath));
574
+ }
575
+ };
576
+ await walk(modulePath, 0);
577
+ out.sort();
578
+ return out;
579
+ }
580
+ /** Est-ce un test unit ? (seul lançable par `vitest run <file>` sans serveur). */
581
+ function isUnitTest(relPath) {
582
+ return relPath.includes("/unit/") || relPath.includes("tests/unit/");
583
+ }
584
+ /** Catégorie d'un fichier de test, dérivée de son chemin/nom (dossier de suite). */
585
+ function testCategory(relPath) {
586
+ if (isUnitTest(relPath)) return "unit";
587
+ if (relPath.includes("/integration/")) return "integration";
588
+ if (relPath.includes("/e2e/") || relPath.endsWith(".e2e.test.ts")) return "e2e";
589
+ if (relPath.includes("/load/")) return "load";
590
+ if (relPath.includes("/websockets/")) return "websockets";
591
+ if (relPath.includes("/routing/")) return "routing";
592
+ if (relPath.endsWith("memory.test.ts")) return "memory";
593
+ return "autre";
594
+ }
595
+ /**
596
+ * Liste les fichiers de test **unit** d'un module (lançables par `vitest run`).
597
+ * Si aucune suite unit, repli sur tous les fichiers. Chemins relatifs au module.
598
+ */
599
+ async function listTestFiles(modulePath) {
600
+ const all = await collectTestFiles(modulePath);
601
+ const unit = all.filter(isUnitTest);
602
+ return unit.length ? unit : all;
603
+ }
604
+ const TEST_CATEGORY_ORDER = [
605
+ "unit",
606
+ "integration",
607
+ "e2e",
608
+ "websockets",
609
+ "routing",
610
+ "load",
611
+ "memory",
612
+ "autre"
613
+ ];
614
+ /**
615
+ * Groupe TOUS les fichiers de test d'un module par catégorie (lecture seule pour
616
+ * l'onglet Tests — les non-unit ne sont pas lançables depuis Studio : ils exigent
617
+ * un serveur live / une base, donc `runnable:false`). Donne une vue complète des
618
+ * suites (intégration/e2e/charge/mémoire) qui étaient invisibles auparavant.
619
+ */
620
+ async function listTestGroups(modulePath) {
621
+ const all = await collectTestFiles(modulePath);
622
+ const byCat = /* @__PURE__ */ new Map();
623
+ for (const f of all) {
624
+ const c = testCategory(f);
625
+ let arr = byCat.get(c);
626
+ if (arr === void 0) {
627
+ arr = [];
628
+ byCat.set(c, arr);
629
+ }
630
+ arr.push(f);
631
+ }
632
+ return [...byCat.entries()].sort(([a], [b]) => TEST_CATEGORY_ORDER.indexOf(a) - TEST_CATEGORY_ORDER.indexOf(b)).map(([category, files]) => ({
633
+ category,
634
+ files,
635
+ runnable: category === "unit"
636
+ }));
637
+ }
638
+ /**
639
+ * Compose la commande d'un run de tests de module, sans l'exécuter.
640
+ *
641
+ * - 1 fichier (module vitest) → `npx vitest run <file>` : le chemin est un
642
+ * FILTRE positionnel de vitest. Il ne doit JAMAIS suivre un `--` : vitest
643
+ * ignore tout ce qui vient après, et la suite ENTIÈRE tourne en silence
644
+ * (vécu : 18 fichiers joués pour un seul demandé, verdict « vert »). La
645
+ * défense anti-injection d'argument est la garde de l'appelant (pas de
646
+ * préfixe `-`, pas de `..`, suffixe `.test.ts` — cf KernelAdminApi).
647
+ * - sinon (module vitest) → run-all, reporters coverage FORCÉS vers
648
+ * `.coverage` : l'onglet Coverage de Studio apparaît quelle que soit la
649
+ * config du module (la liste `reporter` dupliquée par module divergeait).
650
+ * - sinon (cœur : monocart, pas de `vitest.config.ts`) → `npm run coverage`.
651
+ *
652
+ * @param modulePath - racine du module (où vit `vitest.config.ts`)
653
+ * @param file - chemin relatif d'UN fichier de test, déjà validé par l'appelant
654
+ * @returns commande, arguments en tableau (spawn sans shell) et libellé du mode
655
+ */
656
+ function testRunCommand(modulePath, file) {
657
+ const hasVitest = existsSync(join(modulePath, "vitest.config.ts"));
658
+ if (file && hasVitest) return {
659
+ cmd: "npx",
660
+ args: [
661
+ "vitest",
662
+ "run",
663
+ file
664
+ ],
665
+ mode: `vitest run ${file}`
666
+ };
667
+ if (hasVitest) return {
668
+ cmd: "npx",
669
+ args: [
670
+ "vitest",
671
+ "run",
672
+ "--coverage",
673
+ "--coverage.reporter=text-summary",
674
+ "--coverage.reporter=json-summary",
675
+ "--coverage.reporter=lcov",
676
+ "--coverage.reportsDirectory=.coverage"
677
+ ],
678
+ mode: "vitest run --coverage (reporters forcés)"
679
+ };
680
+ return {
681
+ cmd: "npm",
682
+ args: ["run", "coverage"],
683
+ mode: "npm run coverage (suite complète)"
684
+ };
685
+ }
686
+ /**
687
+ * Lance les tests d'un module et renvoie un résumé (pass/fail/durée + tail).
688
+ *
689
+ * - 1 fichier (module vitest) → `npx vitest run <file>` (rapide, pass/fail).
690
+ * - sinon → `npm run coverage` (suite complète + refresh coverage ; marche
691
+ * pour vitest comme pour monocart/core).
692
+ *
693
+ * ⚠️ EXÉCUTE un process — appelé UNIQUEMENT derrière le garde dev-only de
694
+ * l'endpoint (cf KernelAdminApi). spawn sans shell + args en tableau (pas
695
+ * d'injection shell). `file` validé en amont (suffixe .test.ts, pas de `..`).
696
+ */
697
+ function runModuleTests(modulePath, file) {
698
+ const { cmd, args, mode } = testRunCommand(modulePath, file);
699
+ const start = Date.now();
700
+ return new Promise((resolve) => {
701
+ let out = "";
702
+ const cap = (d) => {
703
+ out += d.toString();
704
+ if (out.length > 2e5) out = out.slice(-2e5);
705
+ };
706
+ let child;
707
+ try {
708
+ child = spawn(cmd, args, {
709
+ cwd: modulePath,
710
+ env: process.env
711
+ });
712
+ } catch (e) {
713
+ return resolve({
714
+ ok: false,
715
+ code: null,
716
+ passed: 0,
717
+ failed: 0,
718
+ durationMs: 0,
719
+ output: String(e),
720
+ mode
721
+ });
722
+ }
723
+ child.stdout?.on("data", cap);
724
+ child.stderr?.on("data", cap);
725
+ const timer = setTimeout(() => child.kill("SIGKILL"), 18e4);
726
+ let settled = false;
727
+ const settle = (result) => {
728
+ if (settled) return;
729
+ settled = true;
730
+ clearTimeout(timer);
731
+ child.stdout?.off("data", cap);
732
+ child.stderr?.off("data", cap);
733
+ resolve(result);
734
+ };
735
+ child.on("error", (e) => {
736
+ settle({
737
+ ok: false,
738
+ code: null,
739
+ passed: 0,
740
+ failed: 0,
741
+ durationMs: Date.now() - start,
742
+ output: String(e),
743
+ mode
744
+ });
745
+ });
746
+ child.on("close", (code) => {
747
+ const clean = out.replace(/\x1b\[[0-9;]*m/g, "");
748
+ const passed = Number(clean.match(/Tests\s+(\d+)\s+passed/)?.[1] ?? clean.match(/(\d+)\s+passing/)?.[1] ?? 0);
749
+ const failed = Number(clean.match(/(\d+)\s+failed/)?.[1] ?? clean.match(/(\d+)\s+failing/)?.[1] ?? 0);
750
+ settle({
751
+ ok: code === 0,
752
+ code,
753
+ passed,
754
+ failed,
755
+ durationMs: Date.now() - start,
756
+ output: clean.slice(-6e3),
757
+ mode
758
+ });
759
+ });
760
+ });
761
+ }
762
+ /**
763
+ * Chemin disque racine du package core (`nodefony`).
764
+ *
765
+ * Le core n'est **pas** un module chargé (`kernel.getModules()` n'a pas de clé
766
+ * `core`) : c'est le socle de tous les autres. On résout donc son emplacement
767
+ * pour lire ses docs colocalisées (`<core>/docs/*.md`).
768
+ *
769
+ * Dev self-hosted : `<racine de l'app>/src/nodefony`. Fallback prod :
770
+ * résolution du package npm `nodefony` (remontée jusqu'à son `package.json`).
771
+ */
772
+ function resolveCorePath() {
773
+ const devPath = join(appRoot(), "src", "nodefony");
774
+ if (existsSync(join(devPath, "package.json"))) return devPath;
775
+ try {
776
+ let dir = dirname(fileURLToPath(import.meta.resolve("nodefony")));
777
+ for (let i = 0; i < 6; i++) {
778
+ if (existsSync(join(dir, "package.json"))) return dir;
779
+ dir = dirname(dir);
780
+ }
781
+ } catch {}
782
+ return devPath;
783
+ }
784
+ const rel = (abs, modulePath) => abs.startsWith(modulePath) ? abs.slice(modulePath.length).replace(/^\/+/, "") : abs;
785
+ const round2 = (n) => Math.round(n * 100) / 100;
786
+ /** Parse un `coverage-summary.json` (istanbul/vitest : total + par fichier). */
787
+ function parseSummary(json, modulePath) {
788
+ const total = json.total;
789
+ if (!total) return null;
790
+ const pct = (m) => typeof m?.pct === "number" ? m.pct : 0;
791
+ const files = [];
792
+ for (const [abs, v] of Object.entries(json)) {
793
+ if (abs === "total") continue;
794
+ const m = v;
795
+ files.push({
796
+ file: rel(abs, modulePath),
797
+ lines: pct(m.lines),
798
+ statements: pct(m.statements),
799
+ functions: pct(m.functions),
800
+ branches: pct(m.branches)
801
+ });
802
+ }
803
+ return {
804
+ available: true,
805
+ total: {
806
+ lines: pct(total.lines),
807
+ statements: pct(total.statements),
808
+ functions: pct(total.functions),
809
+ branches: pct(total.branches)
810
+ },
811
+ files
812
+ };
813
+ }
814
+ /** Parse un `lcov.info` (produit par monocart ET vitest) en {total, files}. */
815
+ function parseLcov(text, modulePath) {
816
+ const files = [];
817
+ const tot = {
818
+ lf: 0,
819
+ lh: 0,
820
+ fnf: 0,
821
+ fnh: 0,
822
+ brf: 0,
823
+ brh: 0
824
+ };
825
+ let cur = null;
826
+ const num = (s, i) => Number(s.slice(i)) || 0;
827
+ const pctOf = (h, f) => f > 0 ? round2(h / f * 100) : 100;
828
+ for (const line of text.split("\n")) if (line.startsWith("SF:")) cur = {
829
+ p: line.slice(3).trim(),
830
+ lf: 0,
831
+ lh: 0,
832
+ fnf: 0,
833
+ fnh: 0,
834
+ brf: 0,
835
+ brh: 0
836
+ };
837
+ else if (!cur) continue;
838
+ else if (line.startsWith("LF:")) cur.lf = num(line, 3);
839
+ else if (line.startsWith("LH:")) cur.lh = num(line, 3);
840
+ else if (line.startsWith("FNF:")) cur.fnf = num(line, 4);
841
+ else if (line.startsWith("FNH:")) cur.fnh = num(line, 4);
842
+ else if (line.startsWith("BRF:")) cur.brf = num(line, 4);
843
+ else if (line.startsWith("BRH:")) cur.brh = num(line, 4);
844
+ else if (line.startsWith("end_of_record")) {
845
+ const ln = pctOf(cur.lh, cur.lf);
846
+ files.push({
847
+ file: rel(cur.p, modulePath),
848
+ lines: ln,
849
+ statements: ln,
850
+ functions: pctOf(cur.fnh, cur.fnf),
851
+ branches: pctOf(cur.brh, cur.brf)
852
+ });
853
+ tot.lf += cur.lf;
854
+ tot.lh += cur.lh;
855
+ tot.fnf += cur.fnf;
856
+ tot.fnh += cur.fnh;
857
+ tot.brf += cur.brf;
858
+ tot.brh += cur.brh;
859
+ cur = null;
860
+ }
861
+ if (!files.length) return null;
862
+ const ln = pctOf(tot.lh, tot.lf);
863
+ return {
864
+ available: true,
865
+ total: {
866
+ lines: ln,
867
+ statements: ln,
868
+ functions: pctOf(tot.fnh, tot.fnf),
869
+ branches: pctOf(tot.brh, tot.brf)
870
+ },
871
+ files
872
+ };
873
+ }
874
+ /**
875
+ * Lit le dernier rapport de couverture d'un module dans `<module>/.coverage/`.
876
+ * Préfère `coverage-summary.json` (vitest, a les statements) ; sinon parse
877
+ * `lcov.info` (produit par monocart côté core ET par vitest). Studio AFFICHE ce
878
+ * rapport — il ne lance pas les tests. `available:false` si rien de généré.
879
+ */
880
+ async function readCoverage(modulePath) {
881
+ const dir = join(modulePath, ".coverage");
882
+ let report = null;
883
+ let usedFile = null;
884
+ const summaryPath = join(dir, "coverage-summary.json");
885
+ try {
886
+ report = parseSummary(JSON.parse(await readFile(summaryPath, "utf8")), modulePath);
887
+ if (report) usedFile = summaryPath;
888
+ } catch {}
889
+ if (!report) {
890
+ const lcovPath = join(dir, "lcov.info");
891
+ try {
892
+ report = parseLcov(await readFile(lcovPath, "utf8"), modulePath);
893
+ if (report) usedFile = lcovPath;
894
+ } catch {}
895
+ }
896
+ if (!report) return { available: false };
897
+ report.files.sort((a, b) => a.file.localeCompare(b.file));
898
+ try {
899
+ report.generated = usedFile ? (await stat(usedFile)).mtime.toISOString() : null;
900
+ } catch {
901
+ report.generated = null;
902
+ }
903
+ return report;
904
+ }
905
+ /**
906
+ * Métadonnées du core pour Studio (carte + onglet Vue d'ensemble).
907
+ *
908
+ * `name` est forcé à `@nodefony/core` (cohérent avec `.ai/symbols.json` et le
909
+ * frontmatter `module:`), même si son `package.json` se nomme `nodefony`.
910
+ * `version`/`dependencies` viennent de ce `package.json`.
911
+ */
912
+ async function readCoreInfo() {
913
+ const path = resolveCorePath();
914
+ let version = null;
915
+ let dependencies = [];
916
+ try {
917
+ const pkg = JSON.parse(await readFile(join(path, "package.json"), "utf8"));
918
+ version = typeof pkg.version === "string" ? pkg.version : null;
919
+ dependencies = [...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.peerDependencies ?? {})];
920
+ } catch {}
921
+ return {
922
+ path,
923
+ name: CORE_PACKAGE,
924
+ version,
925
+ dependencies
926
+ };
927
+ }
928
+ //#endregion
929
+ export { CORE_PACKAGE, checkOutdated, countModuleDocs, listModuleDocs, listModuleSymbols, listTestFiles, listTestGroups, parseFrontmatter, readCoreInfo, readCoverage, readDependencies, readModuleDoc, readSymbolDeclaration, resolveCorePath, runModuleTests, searchModuleDocs, testRunCommand };