@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.
- package/LICENSE +544 -0
- package/README.md +50 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/index.js +211 -0
- package/dist/nodefony/config/config.js +61 -0
- package/dist/nodefony/config/defineModuleConfig.js +36 -0
- package/dist/nodefony/controller/AdminApiController.js +163 -0
- package/dist/nodefony/controller/ApiKeyController.js +151 -0
- package/dist/nodefony/controller/BenchController.js +132 -0
- package/dist/nodefony/controller/IssuerMetadataController.js +148 -0
- package/dist/nodefony/controller/OAuth2Controller.js +133 -0
- package/dist/nodefony/controller/ProtectedResourceMetadataController.js +221 -0
- package/dist/nodefony/controller/SessionAuthController.js +141 -0
- package/dist/nodefony/controller/TokenAuthController.js +113 -0
- package/dist/nodefony/controller/TotpController.js +129 -0
- package/dist/nodefony/controller/WebAuthnController.js +242 -0
- package/dist/nodefony/controller/oauthAuthority.js +74 -0
- package/dist/nodefony/decorators/routerDecorators.js +967 -0
- package/dist/nodefony/interfaces/IAdminBroker.js +1 -0
- package/dist/nodefony/interfaces/IController.js +1 -0
- package/dist/nodefony/interfaces/IIdempotencyStore.js +1 -0
- package/dist/nodefony/interfaces/IResolver.js +1 -0
- package/dist/nodefony/interfaces/IRoute.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/service/AdminBroker.js +106 -0
- package/dist/nodefony/service/Eta.js +68 -0
- package/dist/nodefony/service/IdempotencyStore.js +136 -0
- package/dist/nodefony/service/router.js +243 -0
- package/dist/nodefony/src/Controller.js +515 -0
- package/dist/nodefony/src/FrameworkAdminApi.js +268 -0
- package/dist/nodefony/src/KernelAdminApi.js +1243 -0
- package/dist/nodefony/src/PlaygroundAdminApi.js +97 -0
- package/dist/nodefony/src/RedisIdempotencyStore.js +254 -0
- package/dist/nodefony/src/Resolver.js +416 -0
- package/dist/nodefony/src/ResourceController.js +148 -0
- package/dist/nodefony/src/Route.js +476 -0
- package/dist/nodefony/src/SyslogAdminApi.js +466 -0
- package/dist/nodefony/src/Template.js +15 -0
- package/dist/nodefony/src/configMutation.js +186 -0
- package/dist/nodefony/src/docsReader.js +929 -0
- package/dist/nodefony/src/idempotency.js +137 -0
- package/dist/nodefony/src/idempotencyGc.js +32 -0
- package/dist/nodefony/src/idempotencyStoreRegistry.js +36 -0
- package/dist/nodefony/src/scopeCatalog.js +40 -0
- package/dist/nodefony/src/syslogFilters.js +51 -0
- package/dist/types/index.d.ts +96 -0
- package/dist/types/nodefony/config/config.d.ts +41 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
- package/dist/types/nodefony/controller/AdminApiController.d.ts +70 -0
- package/dist/types/nodefony/controller/ApiKeyController.d.ts +49 -0
- package/dist/types/nodefony/controller/BenchController.d.ts +45 -0
- package/dist/types/nodefony/controller/IssuerMetadataController.d.ts +86 -0
- package/dist/types/nodefony/controller/OAuth2Controller.d.ts +81 -0
- package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +147 -0
- package/dist/types/nodefony/controller/SessionAuthController.d.ts +74 -0
- package/dist/types/nodefony/controller/TokenAuthController.d.ts +56 -0
- package/dist/types/nodefony/controller/TotpController.d.ts +42 -0
- package/dist/types/nodefony/controller/WebAuthnController.d.ts +123 -0
- package/dist/types/nodefony/controller/oauthAuthority.d.ts +54 -0
- package/dist/types/nodefony/decorators/routerDecorators.d.ts +632 -0
- package/dist/types/nodefony/interfaces/IAdminBroker.d.ts +79 -0
- package/dist/types/nodefony/interfaces/IController.d.ts +38 -0
- package/dist/types/nodefony/interfaces/IIdempotencyStore.d.ts +1 -0
- package/dist/types/nodefony/interfaces/IResolver.d.ts +25 -0
- package/dist/types/nodefony/interfaces/IRoute.d.ts +31 -0
- package/dist/types/nodefony/interfaces/index.d.ts +5 -0
- package/dist/types/nodefony/service/AdminBroker.d.ts +37 -0
- package/dist/types/nodefony/service/Eta.d.ts +25 -0
- package/dist/types/nodefony/service/IdempotencyStore.d.ts +45 -0
- package/dist/types/nodefony/service/router.d.ts +53 -0
- package/dist/types/nodefony/src/Controller.d.ts +193 -0
- package/dist/types/nodefony/src/FrameworkAdminApi.d.ts +35 -0
- package/dist/types/nodefony/src/KernelAdminApi.d.ts +71 -0
- package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +98 -0
- package/dist/types/nodefony/src/RedisIdempotencyStore.d.ts +104 -0
- package/dist/types/nodefony/src/Resolver.d.ts +165 -0
- package/dist/types/nodefony/src/ResourceController.d.ts +171 -0
- package/dist/types/nodefony/src/Route.d.ts +192 -0
- package/dist/types/nodefony/src/SyslogAdminApi.d.ts +38 -0
- package/dist/types/nodefony/src/Template.d.ts +8 -0
- package/dist/types/nodefony/src/configMutation.d.ts +109 -0
- package/dist/types/nodefony/src/docsReader.d.ts +369 -0
- package/dist/types/nodefony/src/idempotency.d.ts +96 -0
- package/dist/types/nodefony/src/idempotencyGc.d.ts +30 -0
- package/dist/types/nodefony/src/idempotencyStoreRegistry.d.ts +59 -0
- package/dist/types/nodefony/src/scopeCatalog.d.ts +26 -0
- package/dist/types/nodefony/src/syslogFilters.d.ts +52 -0
- package/docs/admin.md +451 -0
- package/docs/controller.md +645 -0
- package/docs/decorateurs.md +845 -0
- package/docs/idempotence.md +741 -0
- package/docs/index.md +151 -0
- package/docs/routing.md +648 -0
- package/docs/templates.md +380 -0
- 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 };
|