tina4-nodejs 3.11.18 → 3.11.19

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.
@@ -0,0 +1,1231 @@
1
+ /**
2
+ * Tina4 Live API RAG — `Docs` module.
3
+ *
4
+ * Walks framework packages (`@tina4/core`, `@tina4/orm`, `@tina4/swagger`,
5
+ * `@tina4/frond`) and the user project's `src/` tree via lightweight TS
6
+ * regex parsing (no AST — works on .ts files without importing them, so
7
+ * user-code import errors don't break reflection).
8
+ *
9
+ * Exposes ranked search, class/method specs, a flat index, MCP-style
10
+ * static mirrors, and a Markdown drift/sync helper. Zero new runtime
11
+ * dependencies — Node stdlib only.
12
+ *
13
+ * Spec: plan/v3/22-LIVE-API-RAG.md
14
+ *
15
+ * Method names follow Tina4 Node.js convention (camelCase) — see PHP
16
+ * `Tina4\Docs` and Python `tina4_python.docs.Docs` for parity references.
17
+ */
18
+
19
+ import * as fs from "node:fs";
20
+ import * as path from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ // ── Types ────────────────────────────────────────────────────────────
24
+
25
+ export interface DocsHit {
26
+ fqn: string;
27
+ kind: "class" | "method" | "function" | "property";
28
+ name: string;
29
+ signature: string;
30
+ summary: string;
31
+ file: string;
32
+ line: number;
33
+ version: string;
34
+ source: "framework" | "user" | "vendor";
35
+ visibility: "public" | "protected" | "private";
36
+ static?: boolean;
37
+ class?: string;
38
+ score: number;
39
+ }
40
+
41
+ export interface MethodSpec {
42
+ name: string;
43
+ fqn: string;
44
+ class: string;
45
+ kind: "method";
46
+ signature: string;
47
+ summary: string;
48
+ docblock: string;
49
+ file: string;
50
+ line: number;
51
+ visibility: "public" | "protected" | "private";
52
+ static: boolean;
53
+ source: "framework" | "user" | "vendor";
54
+ version: string;
55
+ params: Array<{ name: string; type: string; default?: string | null }>;
56
+ return: string;
57
+ }
58
+
59
+ export interface ClassSpec {
60
+ fqn: string;
61
+ kind: "class";
62
+ name: string;
63
+ file: string;
64
+ line: number;
65
+ summary: string;
66
+ docblock: string;
67
+ source: "framework" | "user" | "vendor";
68
+ version: string;
69
+ methods: Array<Omit<MethodSpec, "params" | "return"> & { params?: unknown[]; return?: string }>;
70
+ properties: unknown[];
71
+ }
72
+
73
+ export interface IndexEntry {
74
+ fqn: string;
75
+ kind: "class" | "method" | "function" | "property";
76
+ name: string;
77
+ signature: string;
78
+ summary: string;
79
+ file: string;
80
+ line: number;
81
+ version: string;
82
+ source: "framework" | "user" | "vendor";
83
+ visibility: "public" | "protected" | "private";
84
+ static?: boolean;
85
+ class?: string;
86
+ }
87
+
88
+ export interface DriftHit {
89
+ method: string;
90
+ line: number;
91
+ block: string;
92
+ }
93
+
94
+ interface InternalEntry extends IndexEntry {
95
+ docblock: string;
96
+ _private: boolean;
97
+ }
98
+
99
+ // ── Constants ────────────────────────────────────────────────────────
100
+
101
+ const STDLIB_ALLOWLIST = new Set<string>([
102
+ // JS / TS commonly referenced in docs
103
+ "push", "pop", "shift", "unshift", "slice", "splice", "indexOf", "includes",
104
+ "map", "filter", "reduce", "forEach", "some", "every", "find", "findIndex",
105
+ "join", "split", "replace", "replaceAll", "trim", "toLowerCase", "toUpperCase",
106
+ "startsWith", "endsWith", "concat", "charAt", "charCodeAt", "padStart", "padEnd",
107
+ "keys", "values", "entries", "fromEntries", "assign", "freeze", "isArray",
108
+ "stringify", "parse", "log", "info", "warn", "error", "debug", "table",
109
+ "then", "catch", "finally", "all", "race", "resolve", "reject",
110
+ "setTimeout", "setInterval", "clearTimeout", "clearInterval",
111
+ "JSON", "Math", "Date", "Promise", "Array", "Object", "String", "Number",
112
+ "abs", "floor", "ceil", "round", "min", "max", "pow", "sqrt", "random",
113
+ "bind", "call", "apply", "toString", "valueOf", "hasOwnProperty",
114
+ // Node-ish
115
+ "readFileSync", "writeFileSync", "existsSync", "readdirSync", "statSync",
116
+ "createServer", "listen", "close", "on", "off", "emit", "once",
117
+ "json", "send", "redirect", "html", "status", "end",
118
+ // PHP / Python doc names occasionally used in cross-language docs
119
+ "render", "save", "delete", "find", "select", "where", "all", "count",
120
+ "exists", "fetch", "execute", "insert", "update", "commit", "rollback",
121
+ "get", "set", "has", "put", "patch", "post", "head", "options",
122
+ "encode", "decode", "sign", "verify", "hash",
123
+ ]);
124
+
125
+ const _DOCS_CACHE = new Map<string, Docs>();
126
+
127
+ // ── Helpers ──────────────────────────────────────────────────────────
128
+
129
+ const __FILENAME = fileURLToPath(import.meta.url);
130
+ const __DIRNAME = path.dirname(__FILENAME);
131
+
132
+ /**
133
+ * Resolve the absolute path of the framework's `packages/` root.
134
+ *
135
+ * When running from the monorepo, `import.meta.url` lands inside
136
+ * `packages/core/src/docs.ts`. We walk up to find the workspace root.
137
+ * In a published install, framework code lives under
138
+ * `node_modules/@tina4/core/src/...` — same shape from this file.
139
+ */
140
+ function detectFrameworkRoots(): string[] {
141
+ const corePackageRoot = path.resolve(__DIRNAME, ".."); // packages/core
142
+ const packagesRoot = path.resolve(corePackageRoot, "..");
143
+ const roots: string[] = [];
144
+ for (const pkg of ["core", "orm", "swagger", "frond"]) {
145
+ const dir = path.join(packagesRoot, pkg, "src");
146
+ if (fs.existsSync(dir)) roots.push(dir);
147
+ }
148
+ return roots;
149
+ }
150
+
151
+ /** Same set of dirs to scan under a project root for user code. */
152
+ const USER_DIRS = ["src/orm", "src/routes", "src/app", "src/services", "src/models"];
153
+
154
+ function detectVersion(projectRoot: string): string {
155
+ for (const candidate of [
156
+ path.join(projectRoot, "package.json"),
157
+ path.resolve(__DIRNAME, "..", "..", "..", "package.json"),
158
+ path.resolve(__DIRNAME, "..", "package.json"),
159
+ ]) {
160
+ try {
161
+ const pkg = JSON.parse(fs.readFileSync(candidate, "utf-8"));
162
+ if (pkg && typeof pkg.version === "string") return pkg.version;
163
+ } catch { /* keep looking */ }
164
+ }
165
+ return "0.0.0";
166
+ }
167
+
168
+ function tagSource(absPath: string, frameworkRoots: string[], userRoot: string): "framework" | "user" | "vendor" {
169
+ const norm = path.resolve(absPath);
170
+ for (const fw of frameworkRoots) {
171
+ if (norm === fw || norm.startsWith(fw + path.sep)) return "framework";
172
+ }
173
+ if (norm.includes(`${path.sep}node_modules${path.sep}@tina4${path.sep}`)) return "framework";
174
+ if (norm === userRoot || norm.startsWith(userRoot + path.sep)) return "user";
175
+ return "vendor";
176
+ }
177
+
178
+ function relativePath(absPath: string, projectRoot: string, frameworkRoots: string[]): string {
179
+ const norm = path.resolve(absPath);
180
+ for (const fw of frameworkRoots) {
181
+ const parent = path.dirname(fw); // …/packages/<pkg>
182
+ if (norm.startsWith(parent + path.sep)) {
183
+ // Strip down to "packages/<pkg>/src/foo.ts" relative to monorepo root
184
+ const monorepo = path.resolve(parent, "..", "..");
185
+ if (norm.startsWith(monorepo + path.sep)) return path.relative(monorepo, norm);
186
+ return path.relative(parent, norm);
187
+ }
188
+ }
189
+ if (norm.startsWith(projectRoot + path.sep)) return path.relative(projectRoot, norm);
190
+ return norm;
191
+ }
192
+
193
+ const CAMEL_SPLIT = /([a-z0-9])([A-Z])|([A-Z]+)([A-Z][a-z])/g;
194
+
195
+ function tokenise(text: string): string[] {
196
+ if (!text) return [];
197
+ const expanded = text
198
+ .replace(CAMEL_SPLIT, (_m, a, b, c, d) => (a ? `${a} ${b}` : `${c} ${d}`))
199
+ .toLowerCase();
200
+ return expanded
201
+ .split(/[\s_\-./:,;()\[\]{}\\]+/)
202
+ .filter((p) => p.length > 0);
203
+ }
204
+
205
+ function summariseDoc(doc: string): string {
206
+ if (!doc) return "";
207
+ for (const raw of doc.split(/\r?\n/)) {
208
+ const clean = raw.replace(/^\s*\/?\*+\/?\s?/, "").trim();
209
+ if (!clean || clean.startsWith("@")) continue;
210
+ return clean.slice(0, 240);
211
+ }
212
+ return "";
213
+ }
214
+
215
+ function docblockBody(doc: string): string {
216
+ if (!doc) return "";
217
+ const lines: string[] = [];
218
+ for (const raw of doc.split(/\r?\n/)) {
219
+ const clean = raw.replace(/^\s*\/?\*+\/?\s?/, "").trim();
220
+ if (!clean) continue;
221
+ lines.push(clean);
222
+ }
223
+ return lines.join(" ");
224
+ }
225
+
226
+ // ── TS regex parser ──────────────────────────────────────────────────
227
+
228
+ interface ParsedClass {
229
+ name: string;
230
+ line: number;
231
+ doc: string;
232
+ exported: boolean;
233
+ methods: ParsedMethod[];
234
+ }
235
+
236
+ interface ParsedMethod {
237
+ name: string;
238
+ line: number;
239
+ doc: string;
240
+ signature: string;
241
+ visibility: "public" | "protected" | "private";
242
+ static: boolean;
243
+ }
244
+
245
+ interface ParsedFile {
246
+ classes: ParsedClass[];
247
+ functions: ParsedMethod[];
248
+ }
249
+
250
+ const CLASS_RE = /(?:^|\n)([ \t]*)((?:export\s+(?:default\s+)?(?:abstract\s+)?)?class\s+([A-Za-z_$][\w$]*))[\s\S]*?(?=\n[ \t]*(?:export\s+(?:default\s+)?(?:abstract\s+)?class|export\s+function|function|$))/g;
251
+
252
+ /**
253
+ * Parse a TS source string. Lightweight — finds top-level classes and their
254
+ * public methods, plus top-level exported functions. Captures preceding JSDoc.
255
+ *
256
+ * Strategy: scan token-by-token. We don't need a full AST — we only care
257
+ * about identifying class declarations, brace depth (to find class members),
258
+ * method/function declarations, and JSDoc comments immediately above.
259
+ */
260
+ function parseTypeScript(source: string, _debugTag = ""): ParsedFile {
261
+ const classes: ParsedClass[] = [];
262
+ const functions: ParsedMethod[] = [];
263
+
264
+ // Strip line comments and string contents (preserve length for line numbers).
265
+ const stripped = stripStrings(source);
266
+ const lines = source.split(/\r?\n/);
267
+
268
+ let i = 0;
269
+ let line = 1;
270
+ let pendingDoc = "";
271
+ let pendingDocLine = 0;
272
+ const len = stripped.length;
273
+ let braceDepth = 0;
274
+ let classStack: { name: string; bodyStartDepth: number; entry: ParsedClass; isExport: boolean }[] = [];
275
+
276
+ function lineOf(offset: number): number {
277
+ // Count newlines up to offset.
278
+ let l = 1;
279
+ for (let k = 0; k < offset && k < source.length; k++) {
280
+ if (source.charCodeAt(k) === 10) l++;
281
+ }
282
+ return l;
283
+ }
284
+
285
+ while (i < len) {
286
+ const ch = stripped[i];
287
+
288
+ // JSDoc detection
289
+ if (ch === "/" && stripped[i + 1] === "*" && stripped[i + 2] === "*") {
290
+ const end = stripped.indexOf("*/", i + 3);
291
+ if (end === -1) break;
292
+ pendingDoc = source.slice(i, end + 2);
293
+ pendingDocLine = lineOf(i);
294
+ i = end + 2;
295
+ continue;
296
+ }
297
+
298
+ // Skip line comments (already stripped → '/' followed by '/' won't appear in stripped, but be safe)
299
+ if (ch === "/" && stripped[i + 1] === "/") {
300
+ while (i < len && stripped[i] !== "\n") i++;
301
+ continue;
302
+ }
303
+
304
+ // Brace tracking (only outside strings; strings are zeroed in stripped)
305
+ if (ch === "{") {
306
+ braceDepth++;
307
+ i++;
308
+ continue;
309
+ }
310
+ if (ch === "}") {
311
+ braceDepth--;
312
+ // Pop classes whose body just closed
313
+ while (classStack.length > 0 && braceDepth <= classStack[classStack.length - 1].bodyStartDepth) {
314
+ const cls = classStack.pop()!;
315
+ classes.push(cls.entry);
316
+ }
317
+ i++;
318
+ continue;
319
+ }
320
+
321
+ // Look for "class <Name>"
322
+ if (isWordBoundary(stripped, i) && matchKeyword(stripped, i, "class")) {
323
+ // Read modifiers backwards on this line — already covered by pendingDoc capture.
324
+ const after = i + "class".length;
325
+ const nameMatch = /^\s+([A-Za-z_$][\w$]*)/.exec(stripped.slice(after));
326
+ if (nameMatch) {
327
+ const name = nameMatch[1];
328
+ const classLine = lineOf(i);
329
+ // Look for opening '{' starting from after the name
330
+ let j = after + nameMatch[0].length;
331
+ while (j < len && stripped[j] !== "{") j++;
332
+ if (j < len) {
333
+ const isExport = isExportedAt(stripped, lines, classLine, name);
334
+ const entry: ParsedClass = {
335
+ name,
336
+ line: classLine,
337
+ doc: pendingDoc,
338
+ exported: isExport,
339
+ methods: [],
340
+ };
341
+ // Push class context with its bodyStartDepth = current braceDepth
342
+ // (the upcoming '{' will increment braceDepth one above this).
343
+ classStack.push({ name, bodyStartDepth: braceDepth, entry, isExport });
344
+ pendingDoc = "";
345
+ // Move past '{' and increment depth
346
+ braceDepth++;
347
+ i = j + 1;
348
+ continue;
349
+ }
350
+ }
351
+ }
352
+
353
+ // Method or top-level function detection — we only care about either:
354
+ // * methods inside a class body (classStack non-empty AND directly inside class body)
355
+ // * top-level "export function" or "function" declarations
356
+ if (classStack.length > 0
357
+ && braceDepth === classStack[classStack.length - 1].bodyStartDepth + 1) {
358
+ // We're directly inside a class body.
359
+ const m = matchMethodSignature(stripped, source, i);
360
+ if (m) {
361
+ const methodLine = lineOf(m.nameStart ?? i);
362
+ const cls = classStack[classStack.length - 1];
363
+ // Skip private/protected based on TS modifier OR name prefix '_'
364
+ const visibility = m.visibility;
365
+ cls.entry.methods.push({
366
+ name: m.name,
367
+ line: methodLine,
368
+ doc: pendingDoc,
369
+ signature: m.signature,
370
+ visibility,
371
+ static: m.static,
372
+ });
373
+ pendingDoc = "";
374
+ i = m.endIndex;
375
+ continue;
376
+ }
377
+ } else if (braceDepth === 0) {
378
+ // Top-level — look for "export function" or "function"
379
+ const f = matchTopLevelFunction(stripped, source, i);
380
+ if (f) {
381
+ const fLine = lineOf(f.nameStart ?? i);
382
+ functions.push({
383
+ name: f.name,
384
+ line: fLine,
385
+ doc: pendingDoc,
386
+ signature: f.signature,
387
+ visibility: "public",
388
+ static: false,
389
+ });
390
+ pendingDoc = "";
391
+ i = f.endIndex;
392
+ continue;
393
+ }
394
+ }
395
+
396
+ // Whitespace doesn't reset pendingDoc — but most other tokens do.
397
+ if (!/\s/.test(ch)) {
398
+ // Non-whitespace, non-doc-comment — only reset doc if it was a long way back.
399
+ // Be conservative: only reset on punctuation that clearly terminates.
400
+ if (ch === ";") {
401
+ pendingDoc = "";
402
+ }
403
+ }
404
+
405
+ i++;
406
+ }
407
+
408
+ // Any unclosed class (shouldn't happen in valid TS) → flush.
409
+ while (classStack.length > 0) classes.push(classStack.pop()!.entry);
410
+
411
+ // Suppress unused-warning
412
+ void pendingDocLine;
413
+
414
+ return { classes, functions };
415
+ }
416
+
417
+ function isWordBoundary(text: string, i: number): boolean {
418
+ if (i === 0) return true;
419
+ const prev = text.charCodeAt(i - 1);
420
+ // Word chars: A-Z a-z 0-9 _ $
421
+ if ((prev >= 65 && prev <= 90) || (prev >= 97 && prev <= 122) || (prev >= 48 && prev <= 57) || prev === 95 || prev === 36) {
422
+ return false;
423
+ }
424
+ return true;
425
+ }
426
+
427
+ function matchKeyword(text: string, i: number, kw: string): boolean {
428
+ if (text.substr(i, kw.length) !== kw) return false;
429
+ const after = i + kw.length;
430
+ if (after >= text.length) return true;
431
+ const nextCode = text.charCodeAt(after);
432
+ if ((nextCode >= 65 && nextCode <= 90) || (nextCode >= 97 && nextCode <= 122) || (nextCode >= 48 && nextCode <= 57) || nextCode === 95 || nextCode === 36) {
433
+ return false;
434
+ }
435
+ return true;
436
+ }
437
+
438
+ function isExportedAt(_stripped: string, lines: string[], lineNo: number, name: string): boolean {
439
+ // Walk back up to 8 lines and look for "export class <name>" / "export default class <name>"
440
+ const start = Math.max(0, lineNo - 1);
441
+ const exportClass = "export class " + name;
442
+ const exportDefaultClass = "export default class " + name;
443
+ const exportAbstractClass = "export abstract class " + name;
444
+ const justClass = "class " + name;
445
+ for (let l = start; l >= Math.max(0, start - 8); l--) {
446
+ const ln = lines[l] || "";
447
+ if (ln.includes(exportClass) || ln.includes(exportDefaultClass) || ln.includes(exportAbstractClass)) {
448
+ return true;
449
+ }
450
+ if (ln.includes(justClass)) {
451
+ // declared but not exported
452
+ return /\bexport\b/.test(ln);
453
+ }
454
+ }
455
+ return false;
456
+ }
457
+
458
+ interface MethodMatch {
459
+ name: string;
460
+ signature: string;
461
+ endIndex: number;
462
+ nameStart: number;
463
+ visibility: "public" | "protected" | "private";
464
+ static: boolean;
465
+ }
466
+
467
+ const METHOD_HEAD_RE =
468
+ /^([ \t]*)((?:public|protected|private|readonly|static|async|abstract|override|\s)*)([A-Za-z_$][\w$]*)\s*[<(]/;
469
+
470
+ function matchMethodSignature(stripped: string, source: string, i: number): MethodMatch | null {
471
+ // Method must be at start-of-line-ish position.
472
+ if (i > 0) {
473
+ const prev = stripped.charCodeAt(i - 1);
474
+ if (prev !== 10 && prev !== 32 && prev !== 9 && prev !== 123) return null;
475
+ }
476
+ // Take the rest of the current line + a little ahead.
477
+ let lineEnd = stripped.indexOf("\n", i);
478
+ if (lineEnd === -1) lineEnd = stripped.length;
479
+ // Read up to 4 lines for multi-line signatures.
480
+ let chunkEnd = lineEnd;
481
+ for (let extra = 0; extra < 4 && chunkEnd < stripped.length; extra++) {
482
+ const next = stripped.indexOf("\n", chunkEnd + 1);
483
+ if (next === -1) break;
484
+ chunkEnd = next;
485
+ }
486
+ const chunk = stripped.slice(i, chunkEnd + 1);
487
+ const match = METHOD_HEAD_RE.exec(chunk);
488
+ if (!match) return null;
489
+ const modifiers = match[2] || "";
490
+ const name = match[3];
491
+ // Skip reserved words / control-flow that masquerade as method names.
492
+ const reserved = new Set([
493
+ "if", "for", "while", "switch", "return", "do", "try", "catch", "throw",
494
+ "const", "let", "var", "import", "export", "function", "class", "interface",
495
+ "type", "new", "yield", "await", "case", "break", "continue", "else",
496
+ ]);
497
+ if (reserved.has(name)) return null;
498
+
499
+ // Determine visibility from modifiers
500
+ let visibility: "public" | "protected" | "private" = "public";
501
+ if (/\bprivate\b/.test(modifiers)) visibility = "private";
502
+ else if (/\bprotected\b/.test(modifiers)) visibility = "protected";
503
+ const isStatic = /\bstatic\b/.test(modifiers);
504
+
505
+ // Capture signature — read from start of "name" up through matching ')' and optional return type.
506
+ const nameStart = i + match[1].length + match[2].length;
507
+ const result = captureSignature(stripped, source, nameStart, name);
508
+ if (!result) return null;
509
+
510
+ return {
511
+ name,
512
+ signature: result.signature,
513
+ endIndex: result.endIndex,
514
+ nameStart,
515
+ visibility,
516
+ static: isStatic,
517
+ };
518
+ }
519
+
520
+ const FN_HEAD_RE =
521
+ /^((?:export\s+(?:default\s+)?)?(?:async\s+)?function\s+)([A-Za-z_$][\w$]*)\s*[<(]/;
522
+
523
+ function matchTopLevelFunction(stripped: string, source: string, i: number): MethodMatch | null {
524
+ if (i > 0) {
525
+ const prev = stripped.charCodeAt(i - 1);
526
+ if (prev !== 10 && prev !== 32 && prev !== 9) return null;
527
+ }
528
+ let lineEnd = stripped.indexOf("\n", i);
529
+ if (lineEnd === -1) lineEnd = stripped.length;
530
+ let chunkEnd = lineEnd;
531
+ for (let extra = 0; extra < 4 && chunkEnd < stripped.length; extra++) {
532
+ const next = stripped.indexOf("\n", chunkEnd + 1);
533
+ if (next === -1) break;
534
+ chunkEnd = next;
535
+ }
536
+ const chunk = stripped.slice(i, chunkEnd + 1);
537
+ const match = FN_HEAD_RE.exec(chunk);
538
+ if (!match) return null;
539
+ const name = match[2];
540
+ const nameStart = i + match[1].length;
541
+ const result = captureSignature(stripped, source, nameStart, name);
542
+ if (!result) return null;
543
+ return {
544
+ name,
545
+ signature: result.signature,
546
+ endIndex: result.endIndex,
547
+ nameStart,
548
+ visibility: "public",
549
+ static: false,
550
+ };
551
+ }
552
+
553
+ interface CapturedSig {
554
+ signature: string;
555
+ endIndex: number;
556
+ }
557
+
558
+ function captureSignature(stripped: string, source: string, nameStart: number, name: string): CapturedSig | null {
559
+ // Skip the name
560
+ let j = nameStart + name.length;
561
+ // Optional generic <...>
562
+ while (j < stripped.length && /\s/.test(stripped[j])) j++;
563
+ if (stripped[j] === "<") {
564
+ let depth = 0;
565
+ while (j < stripped.length) {
566
+ const c = stripped[j];
567
+ if (c === "<") depth++;
568
+ else if (c === ">") {
569
+ depth--;
570
+ if (depth === 0) { j++; break; }
571
+ }
572
+ j++;
573
+ }
574
+ }
575
+ // Whitespace
576
+ while (j < stripped.length && /\s/.test(stripped[j])) j++;
577
+ if (stripped[j] !== "(") return null;
578
+ // Capture (...) balanced on parens (ignore strings — already stripped)
579
+ const parenStart = j;
580
+ let depth = 0;
581
+ while (j < stripped.length) {
582
+ const c = stripped[j];
583
+ if (c === "(") depth++;
584
+ else if (c === ")") {
585
+ depth--;
586
+ if (depth === 0) { j++; break; }
587
+ }
588
+ j++;
589
+ }
590
+ const parenSegment = source.slice(parenStart, j); // pull from original source for human-readable
591
+ // Optional return type: ": Type" up to '{' or ';' or '=>' or end-of-line for arrow.
592
+ let retStart = j;
593
+ while (retStart < stripped.length && /[ \t]/.test(stripped[retStart])) retStart++;
594
+ let retEnd = retStart;
595
+ let returnType = "";
596
+ if (stripped[retStart] === ":") {
597
+ retEnd = retStart + 1;
598
+ let depthBracket = 0;
599
+ while (retEnd < stripped.length) {
600
+ const c = stripped[retEnd];
601
+ // Stop on a body-opening '{' or terminating ';' at the same depth.
602
+ if (depthBracket === 0 && (c === "{" || c === ";")) break;
603
+ if (depthBracket === 0 && c === "\n") {
604
+ // Arrow-return on next line — stop only if the next non-space is '{'.
605
+ let k2 = retEnd + 1;
606
+ while (k2 < stripped.length && (stripped[k2] === " " || stripped[k2] === "\t")) k2++;
607
+ if (stripped[k2] === "{") break;
608
+ }
609
+ if (c === "<" || c === "(" || c === "[") depthBracket++;
610
+ else if (c === ">" || c === ")" || c === "]") depthBracket--;
611
+ retEnd++;
612
+ }
613
+ returnType = source.slice(retStart, retEnd).trim();
614
+ }
615
+ const cleanedParens = parenSegment.replace(/\s+/g, " ");
616
+ const sig = name + cleanedParens + (returnType ? " " + returnType : "");
617
+ return { signature: sig, endIndex: retEnd };
618
+ }
619
+
620
+ /**
621
+ * Replace string literals and template literal contents with spaces of equal
622
+ * length so brace/paren scanning isn't fooled by characters inside strings.
623
+ * Also strips line + block comments. Newlines are preserved so line numbers
624
+ * line up with the original source.
625
+ */
626
+ function stripStrings(source: string): string {
627
+ const out: string[] = [];
628
+ const len = source.length;
629
+ let i = 0;
630
+ const BACKTICK = String.fromCharCode(96);
631
+ const DOLLAR = "$";
632
+ const OPEN_BRACE = "{";
633
+ const CLOSE_BRACE = "}";
634
+
635
+ while (i < len) {
636
+ const c = source[i];
637
+
638
+ if (c === "/" && source[i + 1] === "*") {
639
+ const end = source.indexOf("*/", i + 2);
640
+ if (end === -1) {
641
+ for (let k = i; k < len; k++) out.push(source[k] === "\n" ? "\n" : " ");
642
+ return out.join("");
643
+ }
644
+ for (let k = i; k < end + 2; k++) out.push(source[k] === "\n" ? "\n" : " ");
645
+ i = end + 2;
646
+ continue;
647
+ }
648
+
649
+ if (c === "/" && source[i + 1] === "/") {
650
+ while (i < len && source[i] !== "\n") {
651
+ out.push(" ");
652
+ i++;
653
+ }
654
+ continue;
655
+ }
656
+
657
+ if (c === '"' || c === "'") {
658
+ out.push(c);
659
+ i++;
660
+ while (i < len) {
661
+ const sc = source[i];
662
+ if (sc === "\\" && i + 1 < len) {
663
+ out.push(" ");
664
+ i += 2;
665
+ continue;
666
+ }
667
+ if (sc === c) {
668
+ out.push(c);
669
+ i++;
670
+ break;
671
+ }
672
+ out.push(sc === "\n" ? "\n" : " ");
673
+ i++;
674
+ }
675
+ continue;
676
+ }
677
+
678
+ if (c === BACKTICK) {
679
+ // Treat the entire template literal — including any ${...} expressions —
680
+ // as opaque content. Replace every char inside with spaces so brace/paren
681
+ // accounting in the outer parser isn't fooled. Newlines preserved.
682
+ out.push(c);
683
+ i++;
684
+ while (i < len) {
685
+ const tc = source[i];
686
+ if (tc === "\\" && i + 1 < len) {
687
+ out.push(" ");
688
+ i += 2;
689
+ continue;
690
+ }
691
+ if (tc === BACKTICK) {
692
+ out.push(BACKTICK);
693
+ i++;
694
+ break;
695
+ }
696
+ if (tc === DOLLAR && source[i + 1] === OPEN_BRACE) {
697
+ // Skip over the entire ${ ... } expression, replacing all chars with
698
+ // spaces (or newlines). Track real brace depth (ignoring nested
699
+ // template literals' own braces, but those are zeroed too).
700
+ out.push(" ");
701
+ out.push(" ");
702
+ i += 2;
703
+ let depth = 1;
704
+ while (i < len && depth > 0) {
705
+ const ic = source[i];
706
+ if (ic === OPEN_BRACE) depth++;
707
+ else if (ic === CLOSE_BRACE) depth--;
708
+ out.push(ic === "\n" ? "\n" : " ");
709
+ i++;
710
+ }
711
+ continue;
712
+ }
713
+ out.push(tc === "\n" ? "\n" : " ");
714
+ i++;
715
+ }
716
+ continue;
717
+ }
718
+
719
+ out.push(c);
720
+ i++;
721
+ }
722
+ return out.join("");
723
+ }
724
+
725
+ // ── Indexer ──────────────────────────────────────────────────────────
726
+
727
+ function walkTsFiles(root: string): string[] {
728
+ const found: string[] = [];
729
+ function visit(dir: string) {
730
+ let entries: fs.Dirent[];
731
+ try {
732
+ entries = fs.readdirSync(dir, { withFileTypes: true });
733
+ } catch {
734
+ return;
735
+ }
736
+ for (const e of entries) {
737
+ if (e.name.startsWith(".")) continue;
738
+ if (e.isDirectory()) {
739
+ if (e.name === "node_modules" || e.name === "tests" || e.name === "test" || e.name === "dist" || e.name === "build") continue;
740
+ visit(path.join(dir, e.name));
741
+ } else if (e.isFile()) {
742
+ if (!e.name.endsWith(".ts") && !e.name.endsWith(".js") && !e.name.endsWith(".mjs")) continue;
743
+ if (e.name.endsWith(".test.ts") || e.name.endsWith(".test.js")) continue;
744
+ if (e.name.endsWith(".d.ts")) continue;
745
+ found.push(path.join(dir, e.name));
746
+ }
747
+ }
748
+ }
749
+ visit(root);
750
+ return found.sort();
751
+ }
752
+
753
+ function buildEntriesForFile(
754
+ absPath: string,
755
+ source: "framework" | "user" | "vendor",
756
+ fwRoots: string[],
757
+ projectRoot: string,
758
+ version: string,
759
+ out: Map<string, InternalEntry>,
760
+ ): void {
761
+ let text: string;
762
+ try {
763
+ text = fs.readFileSync(absPath, "utf-8");
764
+ } catch {
765
+ return;
766
+ }
767
+ if (text.length > 1024 * 1024) return; // skip giant generated files
768
+ let parsed: ParsedFile;
769
+ try {
770
+ parsed = parseTypeScript(text);
771
+ } catch {
772
+ return;
773
+ }
774
+ const rel = relativePath(absPath, projectRoot, fwRoots);
775
+
776
+ for (const cls of parsed.classes) {
777
+ if (!cls.exported && source === "framework") {
778
+ // Internal helper class — skip from framework reflection.
779
+ continue;
780
+ }
781
+ const fqn = cls.name;
782
+ const summary = summariseDoc(cls.doc);
783
+ const docBody = docblockBody(cls.doc);
784
+ out.set(fqn, {
785
+ fqn,
786
+ kind: "class",
787
+ name: cls.name,
788
+ signature: `class ${cls.name}`,
789
+ summary,
790
+ file: rel,
791
+ line: cls.line,
792
+ version,
793
+ source,
794
+ visibility: "public",
795
+ docblock: docBody,
796
+ _private: false,
797
+ });
798
+
799
+ for (const m of cls.methods) {
800
+ const isUnderscore = m.name.startsWith("_");
801
+ const isPrivate = m.visibility !== "public" || isUnderscore;
802
+ const methodFqn = `${fqn}.${m.name}`;
803
+ const summary = summariseDoc(m.doc) || summariseDoc(cls.doc);
804
+ out.set(methodFqn, {
805
+ fqn: methodFqn,
806
+ kind: "method",
807
+ name: m.name,
808
+ class: fqn,
809
+ signature: m.signature,
810
+ summary,
811
+ file: rel,
812
+ line: m.line,
813
+ version,
814
+ source,
815
+ visibility: m.visibility,
816
+ static: m.static,
817
+ docblock: docblockBody(m.doc),
818
+ _private: isPrivate,
819
+ });
820
+ }
821
+ }
822
+
823
+ for (const fn of parsed.functions) {
824
+ // Only emit user-source top-level functions; framework top-level functions are
825
+ // surface API but they explode the index. Keep them — they're searchable.
826
+ const fqn = fn.name;
827
+ if (!out.has(fqn)) {
828
+ out.set(`fn:${fqn}`, {
829
+ fqn,
830
+ kind: "function",
831
+ name: fn.name,
832
+ signature: fn.signature,
833
+ summary: summariseDoc(fn.doc),
834
+ file: rel,
835
+ line: fn.line,
836
+ version,
837
+ source,
838
+ visibility: "public",
839
+ docblock: docblockBody(fn.doc),
840
+ _private: fn.name.startsWith("_"),
841
+ });
842
+ }
843
+ }
844
+ }
845
+
846
+ // ── Drift helpers ────────────────────────────────────────────────────
847
+
848
+ // Backtick-fence regex built dynamically to avoid confusing TS parsers
849
+ // (some toolchains stumble on triple-backtick literals inside regex bodies).
850
+ const FENCE_RE = new RegExp(
851
+ String.fromCharCode(96, 96, 96) + "[a-zA-Z0-9_+-]*\\n([\\s\\S]*?)\\n" + String.fromCharCode(96, 96, 96),
852
+ "g",
853
+ );
854
+ const CALL_RE = /(?:[A-Za-z_$][\w$]*)(?:\.|::|->)([A-Za-z_$][\w$]*)\s*\(/g;
855
+
856
+ function buildLineIndex(text: string): (offset: number) => number {
857
+ const starts = [0];
858
+ for (let i = 0; i < text.length; i++) {
859
+ if (text.charCodeAt(i) === 10) starts.push(i + 1);
860
+ }
861
+ return (offset: number) => {
862
+ let lo = 0;
863
+ let hi = starts.length - 1;
864
+ while (lo < hi) {
865
+ const mid = (lo + hi + 1) >>> 1;
866
+ if (starts[mid] <= offset) lo = mid;
867
+ else hi = mid - 1;
868
+ }
869
+ return lo + 1;
870
+ };
871
+ }
872
+
873
+ // ── Public Docs class ───────────────────────────────────────────────
874
+
875
+ export class Docs {
876
+ private projectRoot: string;
877
+ private frameworkRoots: string[];
878
+ private version: string;
879
+
880
+ private indexCache: Map<string, InternalEntry> | null = null;
881
+ private frameworkEntries: Map<string, InternalEntry> | null = null;
882
+ private userEntries: Map<string, InternalEntry> = new Map();
883
+ private userMtime = 0;
884
+ private frameworkMtime = 0;
885
+
886
+ constructor(projectRoot: string) {
887
+ this.projectRoot = path.resolve(projectRoot);
888
+ this.frameworkRoots = detectFrameworkRoots();
889
+ this.version = detectVersion(this.projectRoot);
890
+ }
891
+
892
+ /**
893
+ * Search the merged framework + user index for query-matching entities.
894
+ * Source filter accepts `all` (default), `framework`, `user`, `vendor`.
895
+ * Private/underscore methods are excluded unless `includePrivate=true`.
896
+ */
897
+ search(query: string, k = 5, source: string = "all", includePrivate = false): DocsHit[] {
898
+ this.ensureIndex();
899
+ const tokens = tokenise(query);
900
+ if (tokens.length === 0) return [];
901
+ const joined = query.toLowerCase().replace(/\s+/g, "");
902
+ const results: Array<DocsHit> = [];
903
+ for (const entry of this.indexCache!.values()) {
904
+ if (source !== "all" && entry.source !== source) continue;
905
+ if (source === "all" && entry.source === "vendor") continue;
906
+ if (!includePrivate && entry._private) continue;
907
+ let score = this.scoreEntry(entry, tokens, joined);
908
+ if (score <= 0) continue;
909
+ if (entry.source === "user") score *= 1.2;
910
+ const hit: DocsHit = {
911
+ fqn: entry.fqn,
912
+ kind: entry.kind,
913
+ name: entry.name,
914
+ signature: entry.signature,
915
+ summary: entry.summary,
916
+ file: entry.file,
917
+ line: entry.line,
918
+ version: entry.version,
919
+ source: entry.source,
920
+ visibility: entry.visibility,
921
+ score: Math.round(score * 10000) / 10000,
922
+ };
923
+ if (entry.class) hit.class = entry.class;
924
+ if (entry.static) hit.static = entry.static;
925
+ results.push(hit);
926
+ }
927
+ results.sort((a, b) => {
928
+ if (b.score !== a.score) return b.score - a.score;
929
+ return a.fqn.localeCompare(b.fqn);
930
+ });
931
+ return results.slice(0, Math.max(1, k));
932
+ }
933
+
934
+ /**
935
+ * Return the full spec for a single class, or `null` if not found.
936
+ */
937
+ classSpec(fqn: string): ClassSpec | null {
938
+ this.ensureIndex();
939
+ const cls = this.indexCache!.get(fqn);
940
+ if (!cls || cls.kind !== "class") return null;
941
+ const methods: ClassSpec["methods"] = [];
942
+ const prefix = `${cls.fqn}.`;
943
+ for (const e of this.indexCache!.values()) {
944
+ if (e.kind !== "method") continue;
945
+ if (!e.fqn.startsWith(prefix)) continue;
946
+ if (e.visibility !== "public") continue;
947
+ methods.push({
948
+ fqn: e.fqn,
949
+ kind: "method",
950
+ name: e.name,
951
+ class: e.class!,
952
+ signature: e.signature,
953
+ summary: e.summary,
954
+ docblock: e.docblock,
955
+ file: e.file,
956
+ line: e.line,
957
+ version: e.version,
958
+ source: e.source,
959
+ visibility: e.visibility,
960
+ static: e.static ?? false,
961
+ });
962
+ }
963
+ return {
964
+ fqn: cls.fqn,
965
+ kind: "class",
966
+ name: cls.name,
967
+ file: cls.file,
968
+ line: cls.line,
969
+ summary: cls.summary,
970
+ docblock: cls.docblock,
971
+ source: cls.source,
972
+ version: cls.version,
973
+ methods,
974
+ properties: [],
975
+ };
976
+ }
977
+
978
+ /**
979
+ * Return the spec for a single method, or `null` if unknown.
980
+ */
981
+ methodSpec(classFqn: string, methodName: string): MethodSpec | null {
982
+ this.ensureIndex();
983
+ const cls = this.indexCache!.get(classFqn);
984
+ if (!cls) return null;
985
+ const key = `${cls.fqn}.${methodName}`;
986
+ const entry = this.indexCache!.get(key);
987
+ if (!entry || entry.kind !== "method") return null;
988
+ return {
989
+ name: entry.name,
990
+ fqn: entry.fqn,
991
+ class: entry.class!,
992
+ kind: "method",
993
+ signature: entry.signature,
994
+ summary: entry.summary,
995
+ docblock: entry.docblock,
996
+ file: entry.file,
997
+ line: entry.line,
998
+ visibility: entry.visibility,
999
+ static: entry.static ?? false,
1000
+ source: entry.source,
1001
+ version: entry.version,
1002
+ params: [],
1003
+ return: "",
1004
+ };
1005
+ }
1006
+
1007
+ /**
1008
+ * Flat list of every reflected entity (classes + methods + functions),
1009
+ * user + framework. Vendor entries are included here for completeness.
1010
+ */
1011
+ index(): IndexEntry[] {
1012
+ this.ensureIndex();
1013
+ const out: IndexEntry[] = [];
1014
+ for (const e of this.indexCache!.values()) {
1015
+ const clean: IndexEntry = {
1016
+ fqn: e.fqn,
1017
+ kind: e.kind,
1018
+ name: e.name,
1019
+ signature: e.signature,
1020
+ summary: e.summary,
1021
+ file: e.file,
1022
+ line: e.line,
1023
+ version: e.version,
1024
+ source: e.source,
1025
+ visibility: e.visibility,
1026
+ };
1027
+ if (e.class) clean.class = e.class;
1028
+ if (e.static) clean.static = e.static;
1029
+ out.push(clean);
1030
+ }
1031
+ return out;
1032
+ }
1033
+
1034
+ // ── MCP-style static mirrors ─────────────────────────────────────
1035
+
1036
+ static mcpSearch(query: string, k = 5, projectRoot?: string, source: string = "all", includePrivate = false): DocsHit[] {
1037
+ return Docs.cached(projectRoot).search(query, k, source, includePrivate);
1038
+ }
1039
+
1040
+ static mcpMethod(classFqn: string, name: string, projectRoot?: string): MethodSpec | null {
1041
+ return Docs.cached(projectRoot).methodSpec(classFqn, name);
1042
+ }
1043
+
1044
+ static mcpClass(fqn: string, projectRoot?: string): ClassSpec | null {
1045
+ return Docs.cached(projectRoot).classSpec(fqn);
1046
+ }
1047
+
1048
+ // ── Drift detector + sync ────────────────────────────────────────
1049
+
1050
+ static checkDocs(mdPath: string, projectRoot?: string): { drift: DriftHit[] } {
1051
+ if (!fs.existsSync(mdPath)) return { drift: [] };
1052
+ const docs = Docs.cached(projectRoot ?? path.dirname(mdPath));
1053
+ const idx = docs.index();
1054
+ const known = new Set<string>();
1055
+ for (const e of idx) known.add(e.name.toLowerCase());
1056
+
1057
+ const text = fs.readFileSync(mdPath, "utf-8");
1058
+ const lineOf = buildLineIndex(text);
1059
+
1060
+ const drift: DriftHit[] = [];
1061
+ let m: RegExpExecArray | null;
1062
+ FENCE_RE.lastIndex = 0;
1063
+ while ((m = FENCE_RE.exec(text)) !== null) {
1064
+ const block = m[1];
1065
+ const blockStart = (m.index ?? 0) + (m[0].length - block.length - 4);
1066
+ let cm: RegExpExecArray | null;
1067
+ CALL_RE.lastIndex = 0;
1068
+ while ((cm = CALL_RE.exec(block)) !== null) {
1069
+ const name = cm[1];
1070
+ if (known.has(name.toLowerCase())) continue;
1071
+ if (STDLIB_ALLOWLIST.has(name)) continue;
1072
+ const offset = blockStart + (cm.index ?? 0);
1073
+ const line = lineOf(offset);
1074
+ const snippetLine = (block.split(/\r?\n/)[0] || "").trim();
1075
+ drift.push({ method: name, line, block: snippetLine });
1076
+ }
1077
+ }
1078
+ return { drift };
1079
+ }
1080
+
1081
+ static syncDocs(mdPath: string, projectRoot?: string): void {
1082
+ const docs = Docs.cached(projectRoot ?? (fs.existsSync(mdPath) ? path.dirname(mdPath) : process.cwd()));
1083
+ const generated = docs.renderGeneratedBlock();
1084
+ const begin = "<!-- BEGIN GENERATED API -->";
1085
+ const end = "<!-- END GENERATED API -->";
1086
+ const existing = fs.existsSync(mdPath) ? fs.readFileSync(mdPath, "utf-8") : "";
1087
+ if (existing.includes(begin) && existing.includes(end)) {
1088
+ const re = new RegExp(
1089
+ begin.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +
1090
+ "[\\s\\S]*?" +
1091
+ end.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
1092
+ );
1093
+ const replaced = existing.replace(re, `${begin}\n${generated}\n${end}`);
1094
+ fs.writeFileSync(mdPath, replaced, "utf-8");
1095
+ return;
1096
+ }
1097
+ fs.writeFileSync(mdPath, `${existing.replace(/\s+$/, "")}\n\n${begin}\n${generated}\n${end}\n`, "utf-8");
1098
+ }
1099
+
1100
+ // ── Internals ────────────────────────────────────────────────────
1101
+
1102
+ private static cached(projectRoot?: string): Docs {
1103
+ const key = path.resolve(projectRoot ?? process.cwd());
1104
+ let inst = _DOCS_CACHE.get(key);
1105
+ if (!inst) {
1106
+ inst = new Docs(key);
1107
+ _DOCS_CACHE.set(key, inst);
1108
+ }
1109
+ return inst;
1110
+ }
1111
+
1112
+ private ensureIndex(): void {
1113
+ // Framework: rebuild only if not built yet OR mtime changed.
1114
+ const fwMtime = this.maxMtime(this.frameworkRoots);
1115
+ if (this.frameworkEntries === null || fwMtime !== this.frameworkMtime) {
1116
+ this.frameworkEntries = new Map();
1117
+ for (const root of this.frameworkRoots) {
1118
+ for (const f of walkTsFiles(root)) {
1119
+ buildEntriesForFile(f, "framework", this.frameworkRoots, this.projectRoot, this.version, this.frameworkEntries);
1120
+ }
1121
+ }
1122
+ this.frameworkMtime = fwMtime;
1123
+ this.indexCache = null;
1124
+ }
1125
+
1126
+ const userMtime = this.maxMtime(USER_DIRS.map((d) => path.join(this.projectRoot, d)));
1127
+ if (this.indexCache === null || userMtime !== this.userMtime) {
1128
+ this.userEntries = new Map();
1129
+ for (const sub of USER_DIRS) {
1130
+ const dir = path.join(this.projectRoot, sub);
1131
+ if (!fs.existsSync(dir)) continue;
1132
+ for (const f of walkTsFiles(dir)) {
1133
+ buildEntriesForFile(f, "user", this.frameworkRoots, this.projectRoot, this.version, this.userEntries);
1134
+ }
1135
+ }
1136
+ this.userMtime = userMtime;
1137
+ // Merge: framework first, user overrides (user has 1.2x boost anyway).
1138
+ this.indexCache = new Map();
1139
+ for (const [k, v] of this.frameworkEntries) this.indexCache.set(k, v);
1140
+ for (const [k, v] of this.userEntries) this.indexCache.set(k, v);
1141
+ }
1142
+ }
1143
+
1144
+ private maxMtime(dirs: string[]): number {
1145
+ let max = 0;
1146
+ for (const dir of dirs) {
1147
+ if (!fs.existsSync(dir)) continue;
1148
+ for (const f of walkTsFiles(dir)) {
1149
+ try {
1150
+ const st = fs.statSync(f);
1151
+ if (st.mtimeMs > max) max = st.mtimeMs;
1152
+ } catch { /* ignore */ }
1153
+ }
1154
+ }
1155
+ return Math.floor(max);
1156
+ }
1157
+
1158
+ private scoreEntry(entry: InternalEntry, tokens: string[], joined: string): number {
1159
+ const name = entry.name.toLowerCase();
1160
+ const stripped = name.replace(/^_+/, "");
1161
+ const summary = entry.summary.toLowerCase();
1162
+ const doc = entry.docblock.toLowerCase();
1163
+ let score = 0;
1164
+
1165
+ if (name === joined || stripped === joined) score += 5;
1166
+
1167
+ const nameTokens = tokenise(entry.name);
1168
+ for (const tk of tokens) {
1169
+ if (!tk) continue;
1170
+ if (name.startsWith(tk) || stripped.startsWith(tk)) {
1171
+ score += 3;
1172
+ continue;
1173
+ }
1174
+ let hit = false;
1175
+ for (const nt of nameTokens) {
1176
+ if (nt === tk) { score += 3; hit = true; break; }
1177
+ if (nt.startsWith(tk)) { score += 2; hit = true; break; }
1178
+ }
1179
+ if (!hit && name.includes(tk)) score += 0.5;
1180
+ }
1181
+ for (const tk of tokens) {
1182
+ if (tk && summary.includes(tk)) score += 2;
1183
+ }
1184
+ for (const tk of tokens) {
1185
+ if (tk && doc.includes(tk)) score += 1;
1186
+ }
1187
+ if (joined && score === 0 && name.includes(joined)) score += 2;
1188
+ return score;
1189
+ }
1190
+
1191
+ private renderGeneratedBlock(): string {
1192
+ this.ensureIndex();
1193
+ const fwClasses: InternalEntry[] = [];
1194
+ const userClasses: InternalEntry[] = [];
1195
+ const methodCounts = new Map<string, number>();
1196
+ for (const e of this.indexCache!.values()) {
1197
+ if (e.kind === "class") {
1198
+ if (e.source === "framework") fwClasses.push(e);
1199
+ else if (e.source === "user") userClasses.push(e);
1200
+ } else if (e.kind === "method" && e.class) {
1201
+ methodCounts.set(e.class, (methodCounts.get(e.class) ?? 0) + 1);
1202
+ }
1203
+ }
1204
+ fwClasses.sort((a, b) => a.fqn.localeCompare(b.fqn));
1205
+ userClasses.sort((a, b) => a.fqn.localeCompare(b.fqn));
1206
+
1207
+ const lines: string[] = [];
1208
+ lines.push(`_Generated by \`@tina4/core/docs\` — version ${this.version}._`);
1209
+ lines.push("");
1210
+ lines.push("## Framework API");
1211
+ lines.push("");
1212
+ lines.push("| Class | Summary | Methods |");
1213
+ lines.push("|---|---|---|");
1214
+ for (const c of fwClasses) {
1215
+ const summary = c.summary.replace(/\|/g, "\\|");
1216
+ lines.push(`| ${c.fqn} | ${summary} | ${methodCounts.get(c.fqn) ?? 0} |`);
1217
+ }
1218
+ if (userClasses.length > 0) {
1219
+ lines.push("");
1220
+ lines.push("## User Surface");
1221
+ lines.push("");
1222
+ lines.push("| Class | Summary | Methods |");
1223
+ lines.push("|---|---|---|");
1224
+ for (const c of userClasses) {
1225
+ const summary = c.summary.replace(/\|/g, "\\|");
1226
+ lines.push(`| ${c.fqn} | ${summary} | ${methodCounts.get(c.fqn) ?? 0} |`);
1227
+ }
1228
+ }
1229
+ return lines.join("\n");
1230
+ }
1231
+ }