tina4-nodejs 3.13.133 → 3.13.135

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 (52) hide show
  1. package/CLAUDE.md +3 -3
  2. package/README.md +2 -2
  3. package/package.json +1 -1
  4. package/packages/cli/dist/bin.js +3213 -3062
  5. package/packages/cli/src/commands/generate.ts +33 -22
  6. package/packages/cli/src/commands/lint.ts +77 -111
  7. package/packages/core/dist/index.js +3122 -2963
  8. package/packages/core/src/.tina4-metrics.json +15004 -0
  9. package/packages/core/src/aiClient.ts +199 -161
  10. package/packages/core/src/devAdmin.ts +46 -14
  11. package/packages/core/src/dispatchPipeline.ts +65 -67
  12. package/packages/core/src/docs.ts +52 -544
  13. package/packages/core/src/docsParser.ts +270 -0
  14. package/packages/core/src/docsScanner.ts +121 -0
  15. package/packages/core/src/docsSignatures.ts +165 -0
  16. package/packages/core/src/index.ts +2 -0
  17. package/packages/core/src/logger.ts +68 -82
  18. package/packages/core/src/mcp.ts +32 -60
  19. package/packages/core/src/messenger.ts +136 -157
  20. package/packages/core/src/middleware.ts +56 -60
  21. package/packages/core/src/plan.ts +78 -70
  22. package/packages/core/src/projectIndex.ts +15 -288
  23. package/packages/core/src/projectIndexExtractors.ts +126 -0
  24. package/packages/core/src/projectIndexStorage.ts +122 -0
  25. package/packages/core/src/push.ts +293 -0
  26. package/packages/core/src/server.ts +187 -183
  27. package/packages/frond/dist/index.js +607 -770
  28. package/packages/frond/src/engine.ts +670 -818
  29. package/packages/orm/dist/index.js +3132 -2976
  30. package/packages/orm/src/adapters/mongodb.ts +99 -144
  31. package/packages/orm/src/baseModel.ts +429 -515
  32. package/packages/orm/src/fakeData.ts +73 -61
  33. package/packages/orm/src/migration.ts +96 -126
  34. package/packages/orm/src/seeder.ts +6 -238
  35. package/packages/orm/src/seederTable.ts +101 -0
  36. package/packages/orm/src/seederTypes.ts +14 -0
  37. package/packages/orm/src/validation.ts +97 -80
  38. package/types/core/src/aiClient.d.ts +5 -0
  39. package/types/core/src/devAdmin.d.ts +23 -1
  40. package/types/core/src/docsParser.d.ts +28 -0
  41. package/types/core/src/docsScanner.d.ts +1 -0
  42. package/types/core/src/docsSignatures.d.ts +11 -0
  43. package/types/core/src/index.d.ts +2 -0
  44. package/types/core/src/messenger.d.ts +8 -0
  45. package/types/core/src/projectIndexExtractors.d.ts +3 -0
  46. package/types/core/src/projectIndexStorage.d.ts +13 -0
  47. package/types/core/src/push.d.ts +45 -0
  48. package/types/frond/src/engine.d.ts +25 -0
  49. package/types/orm/src/fakeData.d.ts +3 -0
  50. package/types/orm/src/seeder.d.ts +3 -89
  51. package/types/orm/src/seederTable.d.ts +9 -0
  52. package/types/orm/src/seederTypes.d.ts +16 -0
@@ -19,6 +19,7 @@
19
19
  import * as fs from "node:fs";
20
20
  import * as path from "node:path";
21
21
  import { fileURLToPath } from "node:url";
22
+ import { parseTypeScript, type ParsedFile } from "./docsParser.js";
22
23
 
23
24
  // ── Types ────────────────────────────────────────────────────────────
24
25
 
@@ -223,505 +224,6 @@ function docblockBody(doc: string): string {
223
224
  return lines.join(" ");
224
225
  }
225
226
 
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
227
  // ── Indexer ──────────────────────────────────────────────────────────
726
228
 
727
229
  function walkTsFiles(root: string): string[] {
@@ -870,6 +372,52 @@ function buildLineIndex(text: string): (offset: number) => number {
870
372
  };
871
373
  }
872
374
 
375
+ function scoreNameToken(name: string, stripped: string, nameTokens: string[], token: string): number {
376
+ if (!token) return 0;
377
+ if (name.startsWith(token) || stripped.startsWith(token)) return 3;
378
+ for (const nameToken of nameTokens) {
379
+ if (nameToken === token) return 3;
380
+ if (nameToken.startsWith(token)) return 2;
381
+ }
382
+ return name.includes(token) ? 0.5 : 0;
383
+ }
384
+
385
+ function scoreName(name: string, stripped: string, nameTokens: string[], tokens: string[], joined: string): number {
386
+ let score = name === joined || stripped === joined ? 5 : 0;
387
+ for (const token of tokens) score += scoreNameToken(name, stripped, nameTokens, token);
388
+ return score;
389
+ }
390
+
391
+ function scoreText(text: string, tokens: string[], weight: number): number {
392
+ let score = 0;
393
+ for (const token of tokens) {
394
+ if (token && text.includes(token)) score += weight;
395
+ }
396
+ return score;
397
+ }
398
+
399
+ function scoreClassQualifier(entry: InternalEntry, name: string, tokens: string[], joined: string): number {
400
+ const parent = (entry.class ?? "").toLowerCase();
401
+ if (!parent) return 0;
402
+ let score = 0;
403
+ const normalized = joined.replace(/[:.]+/g, ".");
404
+ if (normalized === `${parent}.${name}` || normalized === `${parent}.${name.replace(/^_+/, "")}`) score += 6;
405
+ for (const token of tokens) {
406
+ if (token === parent) score += 2.5;
407
+ else if (token && parent.startsWith(token)) score += 1;
408
+ }
409
+ return score;
410
+ }
411
+
412
+ function scoreFqn(entry: InternalEntry, tokens: string[]): number {
413
+ const segments = new Set(entry.fqn.toLowerCase().split(/[.\s:]+/).filter(Boolean));
414
+ let score = 0;
415
+ for (const token of tokens) {
416
+ if (token && segments.has(token)) score += 1;
417
+ }
418
+ return score;
419
+ }
420
+
873
421
  // ── Public Docs class ───────────────────────────────────────────────
874
422
 
875
423
  /**
@@ -1233,52 +781,12 @@ export class Docs {
1233
781
  private scoreEntry(entry: InternalEntry, tokens: string[], joined: string): number {
1234
782
  const name = entry.name.toLowerCase();
1235
783
  const stripped = name.replace(/^_+/, "");
1236
- const summary = entry.summary.toLowerCase();
1237
- const doc = entry.docblock.toLowerCase();
1238
- let score = 0;
1239
-
1240
- if (name === joined || stripped === joined) score += 5;
1241
-
1242
784
  const nameTokens = tokenise(entry.name);
1243
- for (const tk of tokens) {
1244
- if (!tk) continue;
1245
- if (name.startsWith(tk) || stripped.startsWith(tk)) {
1246
- score += 3;
1247
- continue;
1248
- }
1249
- let hit = false;
1250
- for (const nt of nameTokens) {
1251
- if (nt === tk) { score += 3; hit = true; break; }
1252
- if (nt.startsWith(tk)) { score += 2; hit = true; break; }
1253
- }
1254
- if (!hit && name.includes(tk)) score += 0.5;
1255
- }
1256
- for (const tk of tokens) {
1257
- if (tk && summary.includes(tk)) score += 2;
1258
- }
1259
- for (const tk of tokens) {
1260
- if (tk && doc.includes(tk)) score += 1;
1261
- }
1262
- // Class-qualified queries ("Frond.addTest" / "Frond addTest"): score the
1263
- // owning class so the qualifier steers ranking instead of being dead weight.
1264
- const parent = (entry.class ?? "").toLowerCase();
1265
- if (parent) {
1266
- // Normalise `.`/`:`/whitespace in the joined query to a single `.` so
1267
- // "frond.addtest", "frond:addtest" and "frondaddtest" all compare alike.
1268
- const qNorm = joined.replace(/[:.]+/g, ".");
1269
- if (qNorm === `${parent}.${name}` || qNorm === `${parent}.${stripped}`) {
1270
- score += 6; // exact "Class.method" intent — the strongest signal
1271
- }
1272
- for (const tk of tokens) {
1273
- if (tk === parent) score += 2.5;
1274
- else if (tk && parent.startsWith(tk)) score += 1;
1275
- }
1276
- }
1277
- // Any token that is a whole segment of the fqn (module / class / name).
1278
- const fqnSegs = new Set(entry.fqn.toLowerCase().split(/[.\s:]+/).filter(Boolean));
1279
- for (const tk of tokens) {
1280
- if (tk && fqnSegs.has(tk)) score += 1;
1281
- }
785
+ let score = scoreName(name, stripped, nameTokens, tokens, joined);
786
+ score += scoreText(entry.summary.toLowerCase(), tokens, 2);
787
+ score += scoreText(entry.docblock.toLowerCase(), tokens, 1);
788
+ score += scoreClassQualifier(entry, name, tokens, joined);
789
+ score += scoreFqn(entry, tokens);
1282
790
  if (joined && score === 0 && name.includes(joined)) score += 2;
1283
791
  return score;
1284
792
  }