tina4-nodejs 3.13.133 → 3.13.134

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 (50) 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 +3181 -3051
  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 +3090 -2952
  8. package/packages/core/src/.tina4-metrics.json +15004 -0
  9. package/packages/core/src/aiClient.ts +199 -161
  10. package/packages/core/src/dispatchPipeline.ts +65 -67
  11. package/packages/core/src/docs.ts +52 -544
  12. package/packages/core/src/docsParser.ts +270 -0
  13. package/packages/core/src/docsScanner.ts +121 -0
  14. package/packages/core/src/docsSignatures.ts +165 -0
  15. package/packages/core/src/index.ts +2 -0
  16. package/packages/core/src/logger.ts +68 -82
  17. package/packages/core/src/mcp.ts +32 -60
  18. package/packages/core/src/messenger.ts +136 -157
  19. package/packages/core/src/middleware.ts +56 -60
  20. package/packages/core/src/plan.ts +78 -70
  21. package/packages/core/src/projectIndex.ts +15 -288
  22. package/packages/core/src/projectIndexExtractors.ts +126 -0
  23. package/packages/core/src/projectIndexStorage.ts +122 -0
  24. package/packages/core/src/push.ts +281 -0
  25. package/packages/core/src/server.ts +182 -183
  26. package/packages/frond/dist/index.js +607 -770
  27. package/packages/frond/src/engine.ts +670 -818
  28. package/packages/orm/dist/index.js +3100 -2965
  29. package/packages/orm/src/adapters/mongodb.ts +99 -144
  30. package/packages/orm/src/baseModel.ts +429 -515
  31. package/packages/orm/src/fakeData.ts +73 -61
  32. package/packages/orm/src/migration.ts +96 -126
  33. package/packages/orm/src/seeder.ts +6 -238
  34. package/packages/orm/src/seederTable.ts +101 -0
  35. package/packages/orm/src/seederTypes.ts +14 -0
  36. package/packages/orm/src/validation.ts +97 -80
  37. package/types/core/src/aiClient.d.ts +5 -0
  38. package/types/core/src/docsParser.d.ts +28 -0
  39. package/types/core/src/docsScanner.d.ts +1 -0
  40. package/types/core/src/docsSignatures.d.ts +11 -0
  41. package/types/core/src/index.d.ts +2 -0
  42. package/types/core/src/messenger.d.ts +8 -0
  43. package/types/core/src/projectIndexExtractors.d.ts +3 -0
  44. package/types/core/src/projectIndexStorage.d.ts +13 -0
  45. package/types/core/src/push.d.ts +45 -0
  46. package/types/frond/src/engine.d.ts +25 -0
  47. package/types/orm/src/fakeData.d.ts +3 -0
  48. package/types/orm/src/seeder.d.ts +3 -89
  49. package/types/orm/src/seederTable.d.ts +9 -0
  50. package/types/orm/src/seederTypes.d.ts +16 -0
@@ -865,7 +865,7 @@ export class RequestLogger {
865
865
  * Usage:
866
866
  * Router.use(SecurityHeadersMiddleware);
867
867
  */
868
- export class SecurityHeadersMiddleware {
868
+ export class SecurityHeadersMiddleware {
869
869
  static beforeSecurity(req: Tina4Request, res: Tina4Response): [Tina4Request, Tina4Response] {
870
870
  res.header(
871
871
  "X-Frame-Options",
@@ -962,10 +962,45 @@ export class SecurityHeadersMiddleware {
962
962
  if (firstHop) return firstHop === "https";
963
963
  return Boolean((req.socket as { encrypted?: boolean } | undefined)?.encrypted);
964
964
  }
965
- }
966
-
967
- /**
968
- * Class-based CSRF middleware using the before/after convention.
965
+ }
966
+
967
+ function csrfMethodIsSafe(method: string | undefined): boolean {
968
+ const normalized = (method ?? "GET").toUpperCase();
969
+ return normalized === "GET" || normalized === "HEAD" || normalized === "OPTIONS";
970
+ }
971
+
972
+ function csrfRouteSkips(req: Tina4Request): boolean {
973
+ const route = (req as any)._route ?? (req as any).route;
974
+ return Boolean(route?.noAuth);
975
+ }
976
+
977
+ function csrfBearerIsValid(req: Tina4Request, secret: string): boolean {
978
+ const authHeader = req.headers.authorization ?? "";
979
+ if (!authHeader.startsWith("Bearer ")) return false;
980
+ const bearerToken = authHeader.slice(7).trim();
981
+ return Boolean(bearerToken && validToken(bearerToken, secret));
982
+ }
983
+
984
+ function csrfRequestToken(req: Tina4Request): { token: string; fromQuery: boolean } {
985
+ const query = (req as any).query ?? {};
986
+ if (query.formToken) return { token: "", fromQuery: true };
987
+ const body = (req as any).body;
988
+ if (body && typeof body === "object" && body.formToken) return { token: String(body.formToken), fromQuery: false };
989
+ return { token: String(req.headers["x-form-token"] ?? ""), fromQuery: false };
990
+ }
991
+
992
+ function csrfSessionMatches(req: Tina4Request, payload: Record<string, unknown>): boolean {
993
+ const tokenSessionId = payload.session_id as string | undefined;
994
+ if (!tokenSessionId) return true;
995
+ const session = (req as any).session;
996
+ if (!session) return true;
997
+ let currentSessionId = session.session_id ?? session.sessionId ?? session.id;
998
+ if (typeof currentSessionId === "function") currentSessionId = undefined;
999
+ return !currentSessionId || tokenSessionId === currentSessionId;
1000
+ }
1001
+
1002
+ /**
1003
+ * Class-based CSRF middleware using the before/after convention.
969
1004
  * Validates form tokens on state-changing requests (POST, PUT, PATCH, DELETE).
970
1005
  *
971
1006
  * OFF by default — a default app has NO CSRF gate because the middleware is
@@ -1012,17 +1047,7 @@ export class CsrfMiddleware {
1012
1047
  return [req, res];
1013
1048
  }
1014
1049
 
1015
- // Skip safe HTTP methods
1016
- const method = (req.method ?? "GET").toUpperCase();
1017
- if (method === "GET" || method === "HEAD" || method === "OPTIONS") {
1018
- return [req, res];
1019
- }
1020
-
1021
- // Skip routes marked noAuth
1022
- const route = (req as any)._route ?? (req as any).route;
1023
- if (route?.noAuth) {
1024
- return [req, res];
1025
- }
1050
+ if (csrfMethodIsSafe(req.method) || csrfRouteSkips(req)) return [req, res];
1026
1051
 
1027
1052
  // Resolve the signing secret ONCE, fail-closed — IDENTICAL to the validator
1028
1053
  // (auth.ts validToken: `secret ?? process.env.TINA4_SECRET ?? ""`). Blank
@@ -1039,35 +1064,20 @@ export class CsrfMiddleware {
1039
1064
 
1040
1065
  // Skip requests with a valid Bearer token (API clients). Pass the resolved
1041
1066
  // secret so the Bearer check uses the SAME key as the form-token check.
1042
- const authHeader = req.headers.authorization ?? "";
1043
- if (authHeader.startsWith("Bearer ")) {
1044
- const bearerToken = authHeader.slice(7).trim();
1045
- if (bearerToken && validToken(bearerToken, secret)) {
1046
- return [req, res];
1047
- }
1048
- }
1049
-
1050
- // Reject if token is in query string (security risk — a URL leaks through
1051
- // logs, referers and history).
1052
- const query = (req as any).query ?? {};
1053
- if (query.formToken) {
1054
- console.warn("[Tina4 CSRF] Token found in query string — rejected for security");
1055
- return reject("Form token must not be sent in the URL query string");
1056
- }
1057
-
1058
- // Extract token: body first, then header
1059
- let token: string | undefined;
1060
- const body = (req as any).body;
1061
- if (body && typeof body === "object" && body.formToken) {
1062
- token = String(body.formToken);
1063
- }
1064
-
1065
- if (!token) {
1066
- token = (req.headers["x-form-token"] as string) ?? "";
1067
- }
1068
-
1069
- if (!token) {
1070
- return reject("Invalid or missing form token");
1067
+ if (csrfBearerIsValid(req, secret)) return [req, res];
1068
+
1069
+ // Reject if token is in query string (security risk — a URL leaks through
1070
+ // logs, referers and history).
1071
+ const requestToken = csrfRequestToken(req);
1072
+ if (requestToken.fromQuery) {
1073
+ console.warn("[Tina4 CSRF] Token found in query string — rejected for security");
1074
+ return reject("Form token must not be sent in the URL query string");
1075
+ }
1076
+
1077
+ // Extract token: body first, then header
1078
+ const token = requestToken.token;
1079
+ if (!token) {
1080
+ return reject("Invalid or missing form token");
1071
1081
  }
1072
1082
 
1073
1083
  // Validate the token signature / expiry against the resolved secret.
@@ -1086,21 +1096,7 @@ export class CsrfMiddleware {
1086
1096
 
1087
1097
  // Session binding — if token has session_id, verify it matches the request
1088
1098
  // session. A token minted for one session cannot be replayed against another.
1089
- const tokenSessionId = payload.session_id as string | undefined;
1090
- if (tokenSessionId) {
1091
- const session = (req as any).session;
1092
- let currentSessionId: string | undefined;
1093
- if (session) {
1094
- currentSessionId = session.session_id ?? session.sessionId ?? session.id;
1095
- if (typeof currentSessionId === "function") {
1096
- currentSessionId = undefined;
1097
- }
1098
- }
1099
-
1100
- if (currentSessionId && tokenSessionId !== currentSessionId) {
1101
- return reject("Invalid or missing form token");
1102
- }
1103
- }
1099
+ if (!csrfSessionMatches(req, payload)) return reject("Invalid or missing form token");
1104
1100
 
1105
1101
  return [req, res];
1106
1102
  }
@@ -158,6 +158,78 @@ function loadParsed(name: string): { path: string; plan: ParsedPlan } {
158
158
  return { path: p, plan: parse(fs.readFileSync(p, "utf-8")) };
159
159
  }
160
160
 
161
+ function fleshPrompt(title: string, goal: string, existing: string[], prompt: string): { system: string; user: string } {
162
+ const system =
163
+ "You are Tina4, a coding planner embedded in the Tina4 dev " +
164
+ "admin. Return ONLY a JSON array of short imperative step " +
165
+ "strings (no prose, no code-fences, no numbering). 3-8 steps, " +
166
+ "each referencing concrete files/routes/migrations. Example: " +
167
+ '["Create src/orm/Duck.ts with id/name/sighted_at", ' +
168
+ '"Add migration 001_create_ducks.sql", ' +
169
+ '"Add GET/POST/PUT/DELETE /api/ducks routes in ' +
170
+ 'src/routes/ducks.ts"]';
171
+ const parts: string[] = [`Plan title: ${title}`];
172
+ if (goal) parts.push(`Goal: ${goal}`);
173
+ if (existing.length) parts.push("Existing steps (don't repeat):\n- " + existing.join("\n- "));
174
+ if (prompt) parts.push(`Extra context from caller: ${prompt}`);
175
+ parts.push("Reply with ONLY the JSON array — no explanation, no markdown fences.");
176
+ return { system, user: parts.join("\n\n") };
177
+ }
178
+
179
+ async function fetchFleshReply(aiUrl: string, aiModel: string, prompts: { system: string; user: string }): Promise<{ reply: string } | { error: string }> {
180
+ try {
181
+ const response = await fetch(aiUrl, {
182
+ method: "POST",
183
+ headers: { "Content-Type": "application/json" },
184
+ body: JSON.stringify({ model: aiModel, stream: false, messages: [
185
+ { role: "system", content: prompts.system },
186
+ { role: "user", content: prompts.user },
187
+ ] }),
188
+ signal: AbortSignal.timeout(120_000),
189
+ });
190
+ const result = await response.json() as Record<string, unknown>;
191
+ const message = (result.message as Record<string, unknown> | undefined) || {};
192
+ return { reply: ((message.content as string) || (result.response as string) || "") };
193
+ } catch (error) {
194
+ return { error: `AI backend unreachable: ${(error as Error).message}` };
195
+ }
196
+ }
197
+
198
+ function parseFleshReply(reply: string): string[] {
199
+ let body = reply.trim();
200
+ if (body.startsWith("```")) {
201
+ body = body.replace(/^`+/, "").replace(/`+$/, "");
202
+ if (body.toLowerCase().startsWith("json")) body = body.slice(4).trim();
203
+ body = body.trim();
204
+ }
205
+ try {
206
+ const parsed = JSON.parse(body);
207
+ if (Array.isArray(parsed)) return parsed.map((step) => String(step).trim()).filter(Boolean);
208
+ } catch {
209
+ // Fall back to markdown-style lists below.
210
+ }
211
+ const proposed: string[] = [];
212
+ for (const line of reply.split(/\r?\n/)) {
213
+ const match = line.match(/^\s*(?:[-*]|\d+[.)])\s+(.+?)\s*$/);
214
+ if (match) proposed.push(match[1].trim());
215
+ }
216
+ return proposed;
217
+ }
218
+
219
+ function addFleshSteps(target: string, existing: string[], proposed: string[]): string[] {
220
+ const existingLc = new Set(existing.map((step) => step.toLowerCase()));
221
+ const added: string[] = [];
222
+ for (const step of proposed) {
223
+ if (existingLc.has(step.toLowerCase())) continue;
224
+ const result = Plan.addStep(step, target);
225
+ if (result.ok) {
226
+ added.push(step);
227
+ existingLc.add(step.toLowerCase());
228
+ }
229
+ }
230
+ return added;
231
+ }
232
+
161
233
  // ── Plan namespace ────────────────────────────────────────────
162
234
 
163
235
  export const Plan = {
@@ -453,81 +525,17 @@ export const Plan = {
453
525
  const title = currentPlan.title || target;
454
526
  const goal = currentPlan.goal || "";
455
527
 
456
- const systemPrompt =
457
- "You are Tina4, a coding planner embedded in the Tina4 dev " +
458
- "admin. Return ONLY a JSON array of short imperative step " +
459
- "strings (no prose, no code-fences, no numbering). 3-8 steps, " +
460
- "each referencing concrete files/routes/migrations. Example: " +
461
- '["Create src/orm/Duck.ts with id/name/sighted_at", ' +
462
- '"Add migration 001_create_ducks.sql", ' +
463
- '"Add GET/POST/PUT/DELETE /api/ducks routes in ' +
464
- 'src/routes/ducks.ts"]';
465
-
466
- const userParts: string[] = [`Plan title: ${title}`];
467
- if (goal) userParts.push(`Goal: ${goal}`);
468
- if (existing.length) userParts.push("Existing steps (don't repeat):\n- " + existing.join("\n- "));
469
- if (prompt) userParts.push(`Extra context from caller: ${prompt}`);
470
- userParts.push("Reply with ONLY the JSON array — no explanation, no markdown fences.");
471
-
472
528
  const aiUrl = process.env.TINA4_AI_URL || "http://localhost:11437/api/chat";
473
529
  const aiModel = process.env.TINA4_AI_MODEL || "qwen2.5-coder:14b";
474
-
475
- let result: Record<string, unknown>;
476
- try {
477
- const resp = await fetch(aiUrl, {
478
- method: "POST",
479
- headers: { "Content-Type": "application/json" },
480
- body: JSON.stringify({
481
- model: aiModel,
482
- stream: false,
483
- messages: [
484
- { role: "system", content: systemPrompt },
485
- { role: "user", content: userParts.join("\n\n") },
486
- ],
487
- }),
488
- signal: AbortSignal.timeout(120_000),
489
- });
490
- result = (await resp.json()) as Record<string, unknown>;
491
- } catch (e) {
492
- return { ok: false, error: `AI backend unreachable: ${(e as Error).message}` };
493
- }
494
-
495
- const msg = (result.message as Record<string, unknown> | undefined) || {};
496
- const reply = (msg.content as string) || (result.response as string) || "";
497
- let body = reply.trim();
498
- if (body.startsWith("```")) {
499
- body = body.replace(/^`+/, "").replace(/`+$/, "");
500
- if (body.toLowerCase().startsWith("json")) body = body.slice(4).trim();
501
- body = body.trim();
502
- }
503
-
504
- let proposed: string[] = [];
505
- try {
506
- const parsed = JSON.parse(body);
507
- if (Array.isArray(parsed)) {
508
- proposed = parsed.map((x) => String(x).trim()).filter(Boolean);
509
- }
510
- } catch {
511
- for (const line of reply.split(/\r?\n/)) {
512
- const m = line.match(/^\s*(?:[-*]|\d+[.)])\s+(.+?)\s*$/);
513
- if (m) proposed.push(m[1].trim());
514
- }
515
- }
530
+ const prompts = fleshPrompt(title, goal, existing, prompt);
531
+ const fetched = await fetchFleshReply(aiUrl, aiModel, prompts);
532
+ if ("error" in fetched) return { ok: false, error: fetched.error };
533
+ const proposed = parseFleshReply(fetched.reply);
516
534
 
517
535
  if (proposed.length === 0) {
518
- return { ok: false, error: "AI returned no usable steps", raw_reply: reply.slice(0, 400) };
519
- }
520
-
521
- const existingLc = new Set(existing.map((s) => s.toLowerCase()));
522
- const added: string[] = [];
523
- for (const step of proposed) {
524
- if (existingLc.has(step.toLowerCase())) continue;
525
- const res = Plan.addStep(step, target);
526
- if (res.ok) {
527
- added.push(step);
528
- existingLc.add(step.toLowerCase());
529
- }
536
+ return { ok: false, error: "AI returned no usable steps", raw_reply: fetched.reply.slice(0, 400) };
530
537
  }
538
+ const added = addFleshSteps(target, existing, proposed);
531
539
 
532
540
  return {
533
541
  ok: true,
@@ -9,25 +9,10 @@
9
9
  * Python) using regex. No LLM involvement — pure static analysis.
10
10
  */
11
11
 
12
- import * as fs from "node:fs";
13
12
  import * as path from "node:path";
14
- import * as crypto from "node:crypto";
15
-
16
- const INDEX_DIRNAME = ".tina4";
17
- const INDEX_FILENAME = "project_index.json";
18
-
19
- const SKIP_DIRS = new Set([
20
- ".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv",
21
- ".mypy_cache", ".ruff_cache", ".pytest_cache", "dist", "build",
22
- ".tina4", "logs", ".idea", ".vscode",
23
- ]);
24
-
25
- const INDEX_EXT = new Set([
26
- ".py", ".twig", ".html", ".sql", ".scss", ".css", ".js", ".ts",
27
- ".mjs", ".md", ".json", ".yml", ".yaml", ".toml", ".env",
28
- ]);
29
-
30
- const MAX_FILE_BYTES = 256 * 1024;
13
+ import * as fs from "node:fs";
14
+ import { extract, indexPath, loadRaw, projectRoot, saveRaw, walk } from "./projectIndexStorage.js";
15
+ import type { IndexData } from "./projectIndexStorage.js";
31
16
 
32
17
  export interface FileRoute {
33
18
  method: string;
@@ -60,266 +45,20 @@ export interface FileEntry {
60
45
  error?: string;
61
46
  }
62
47
 
63
- interface IndexData {
64
- version: number;
65
- files: Record<string, FileEntry>;
66
- generated_at: number;
67
- }
68
-
69
- function projectRoot(): string {
70
- return path.resolve(process.cwd());
71
- }
72
-
73
- function indexPath(): string {
74
- const d = path.join(projectRoot(), INDEX_DIRNAME);
75
- if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
76
- return path.join(d, INDEX_FILENAME);
77
- }
78
-
79
- // ── Per-language extractors ──────────────────────────────────
80
-
81
- const ROUTE_METHODS = new Set(["get", "post", "put", "patch", "delete", "any_method", "any"]);
82
-
83
- // TypeScript/JavaScript: grab exports and imports + route decorator calls
84
- const JS_EXPORT_RE = /^\s*export\s+(?:default\s+)?(?:async\s+)?(?:function|class|const|let|var|interface|type|enum)\s+([A-Za-z_$][\w$]*)/gm;
85
- const JS_IMPORT_RE = /^\s*import\s+[^'"]+?['"]([^'"]+)['"]/gm;
86
- // Calls like: get("/api/foo", ...) or router.post("/api/foo", ...)
87
- const JS_ROUTE_RE = /(?:^|\W)(?:(?:[A-Za-z_$][\w$]*)\.)?(get|post|put|patch|delete|any)\s*\(\s*['"]([^'"]+)['"]/g;
88
-
89
- function extractJsTs(text: string): FileEntry {
90
- const exports: string[] = [];
91
- const imports: string[] = [];
92
- const routes: FileRoute[] = [];
93
- let m: RegExpExecArray | null;
94
-
95
- JS_EXPORT_RE.lastIndex = 0;
96
- while ((m = JS_EXPORT_RE.exec(text)) !== null) {
97
- if (!exports.includes(m[1])) exports.push(m[1]);
98
- }
99
- JS_IMPORT_RE.lastIndex = 0;
100
- while ((m = JS_IMPORT_RE.exec(text)) !== null) {
101
- if (!imports.includes(m[1])) imports.push(m[1]);
48
+ function scoreSearchEntry(query: string, rel: string, entry: FileEntry): number {
49
+ let score = rel.toLowerCase().includes(query) ? 10 : 0;
50
+ for (const symbol of entry.symbols || []) {
51
+ if (query === symbol.toLowerCase()) score += 8;
52
+ else if (symbol.toLowerCase().includes(query)) score += 4;
102
53
  }
103
- JS_ROUTE_RE.lastIndex = 0;
104
- while ((m = JS_ROUTE_RE.exec(text)) !== null) {
105
- const method = m[1].toUpperCase();
106
- const routePath = m[2];
107
- if (ROUTE_METHODS.has(m[1].toLowerCase()) && !routes.some((r) => r.method === method && r.path === routePath)) {
108
- routes.push({ method, path: routePath, handler: "" });
109
- }
54
+ for (const route of entry.routes || []) {
55
+ if (`${route.path || ""} ${route.handler || ""}`.toLowerCase().includes(query)) score += 5;
110
56
  }
111
-
112
- exports.sort();
113
- imports.sort();
114
- return { exports, imports, routes };
115
- }
116
-
117
- const TWIG_EXTENDS_RE = /\{%\s*extends\s+['"]([^'"]+)['"]\s*%\}/g;
118
- const TWIG_BLOCK_RE = /\{%\s*block\s+([A-Za-z_][\w-]*)/g;
119
- const TWIG_INCLUDE_RE = /\{%\s*include\s+['"]([^'"]+)['"]/g;
120
-
121
- function extractTwig(text: string): FileEntry {
122
- const extendsList: string[] = [];
123
- const blocks = new Set<string>();
124
- const includes = new Set<string>();
125
- let m: RegExpExecArray | null;
126
-
127
- TWIG_EXTENDS_RE.lastIndex = 0;
128
- while ((m = TWIG_EXTENDS_RE.exec(text)) !== null) extendsList.push(m[1]);
129
- TWIG_BLOCK_RE.lastIndex = 0;
130
- while ((m = TWIG_BLOCK_RE.exec(text)) !== null) blocks.add(m[1]);
131
- TWIG_INCLUDE_RE.lastIndex = 0;
132
- while ((m = TWIG_INCLUDE_RE.exec(text)) !== null) includes.add(m[1]);
133
-
134
- return {
135
- extends: extendsList,
136
- blocks: Array.from(blocks).sort(),
137
- includes: Array.from(includes).sort(),
138
- };
139
- }
140
-
141
- const SQL_CREATE_RE = /create\s+(?:unique\s+)?(table|index|view|trigger|sequence|procedure|function)\s+(?:if\s+not\s+exists\s+)?([A-Za-z_][\w.]*)/gi;
142
- const SQL_ALTER_RE = /alter\s+(table|index|view)\s+([A-Za-z_][\w.]*)/gi;
143
-
144
- function extractSql(text: string): FileEntry {
145
- const creates: string[] = [];
146
- const alters: string[] = [];
147
- let m: RegExpExecArray | null;
148
- SQL_CREATE_RE.lastIndex = 0;
149
- while ((m = SQL_CREATE_RE.exec(text)) !== null) creates.push(`${m[1].toUpperCase()} ${m[2]}`);
150
- SQL_ALTER_RE.lastIndex = 0;
151
- while ((m = SQL_ALTER_RE.exec(text)) !== null) alters.push(`${m[1].toUpperCase()} ${m[2]}`);
152
- return { creates, alters };
153
- }
154
-
155
- const MD_H1_RE = /^#\s+(.+)$/m;
156
- const MD_H2_RE = /^##\s+(.+)$/gm;
157
-
158
- function extractMd(text: string): FileEntry {
159
- const h1 = MD_H1_RE.exec(text);
160
- const sections: string[] = [];
161
- let m: RegExpExecArray | null;
162
- MD_H2_RE.lastIndex = 0;
163
- while ((m = MD_H2_RE.exec(text)) !== null && sections.length < 30) {
164
- sections.push(m[1]);
165
- }
166
- return { title: (h1 ? h1[1] : "").trim(), sections };
167
- }
168
-
169
- // Python: cheap regex — no AST in Node.js. Good enough for search.
170
- const PY_CLASS_RE = /^class\s+([A-Za-z_][\w]*)/gm;
171
- const PY_FUNC_RE = /^(?:async\s+)?def\s+([A-Za-z_][\w]*)/gm;
172
- const PY_IMPORT_RE = /^(?:from\s+([A-Za-z_][\w.]*)\s+import|import\s+([A-Za-z_][\w.]*))/gm;
173
- const PY_DECOR_RE = /^@(?:[A-Za-z_][\w]*\.)?(get|post|put|patch|delete|any_method)\s*\(\s*['"]([^'"]+)['"]/gm;
174
-
175
- function extractPython(text: string): FileEntry {
176
- const symbols: string[] = [];
177
- const imports: string[] = [];
178
- const routes: FileRoute[] = [];
179
- let m: RegExpExecArray | null;
180
- PY_CLASS_RE.lastIndex = 0;
181
- while ((m = PY_CLASS_RE.exec(text)) !== null) symbols.push(m[1]);
182
- PY_FUNC_RE.lastIndex = 0;
183
- while ((m = PY_FUNC_RE.exec(text)) !== null) symbols.push(m[1]);
184
- PY_IMPORT_RE.lastIndex = 0;
185
- while ((m = PY_IMPORT_RE.exec(text)) !== null) imports.push(m[1] || m[2]);
186
- PY_DECOR_RE.lastIndex = 0;
187
- while ((m = PY_DECOR_RE.exec(text)) !== null) {
188
- routes.push({ method: m[1].toUpperCase(), path: m[2], handler: "" });
57
+ if ((entry.summary || "").toLowerCase().includes(query)) score += 3;
58
+ for (const imported of entry.imports || []) {
59
+ if (imported.toLowerCase().includes(query)) score += 1;
189
60
  }
190
- // Best-effort docstring
191
- const doc = text.match(/^\s*"""\s*([^\n]+)/);
192
- return {
193
- symbols,
194
- imports,
195
- routes,
196
- docstring: doc ? doc[1].trim().slice(0, 200) : "",
197
- };
198
- }
199
-
200
- function extractGeneric(text: string): FileEntry {
201
- for (const line of text.split(/\r?\n/)) {
202
- const s = line.trim();
203
- if (!s || s.startsWith("<!--")) continue;
204
- return { first_line: s.slice(0, 200) };
205
- }
206
- return {};
207
- }
208
-
209
- const EXTRACTORS: Record<string, (t: string) => FileEntry> = {
210
- ".ts": extractJsTs,
211
- ".js": extractJsTs,
212
- ".mjs": extractJsTs,
213
- ".twig": extractTwig,
214
- ".html": extractTwig,
215
- ".sql": extractSql,
216
- ".md": extractMd,
217
- ".py": extractPython,
218
- };
219
-
220
- function languageFor(p: string): string {
221
- const ext = path.extname(p);
222
- const map: Record<string, string> = {
223
- ".py": "python", ".twig": "twig", ".html": "html", ".sql": "sql",
224
- ".scss": "scss", ".css": "css", ".js": "javascript", ".mjs": "javascript",
225
- ".ts": "typescript", ".md": "markdown", ".json": "json", ".yml": "yaml",
226
- ".yaml": "yaml", ".toml": "toml", ".env": "env",
227
- };
228
- return map[ext] || "text";
229
- }
230
-
231
- function summarise(entry: FileEntry): string {
232
- if (entry.skipped) return entry.skipped;
233
- if (entry.docstring) return entry.docstring;
234
- if (entry.title) return entry.title;
235
- if (entry.routes && entry.routes.length) {
236
- const r = entry.routes[0];
237
- const extra = entry.routes.length > 1 ? ` (+${entry.routes.length - 1} more)` : "";
238
- return `${r.method} ${r.path}${extra}`;
239
- }
240
- if (entry.symbols && entry.symbols.length) {
241
- return "defines " + entry.symbols.slice(0, 4).join(", ");
242
- }
243
- if (entry.exports && entry.exports.length) {
244
- return "exports " + entry.exports.slice(0, 4).join(", ");
245
- }
246
- if (entry.creates && entry.creates.length) {
247
- return "schema: " + entry.creates.slice(0, 3).join(", ");
248
- }
249
- if (entry.extends && entry.extends.length) {
250
- return `template, extends ${entry.extends[0]}`;
251
- }
252
- if (entry.first_line) return entry.first_line;
253
- return "";
254
- }
255
-
256
- function extract(fullPath: string): FileEntry {
257
- let st: fs.Stats;
258
- try {
259
- st = fs.statSync(fullPath);
260
- } catch {
261
- return {};
262
- }
263
- const entry: FileEntry = {
264
- path: path.relative(projectRoot(), fullPath),
265
- size: st.size,
266
- mtime: Math.floor(st.mtimeMs / 1000),
267
- language: languageFor(fullPath),
268
- };
269
- if (st.size > MAX_FILE_BYTES) {
270
- entry.skipped = `too large (${st.size} bytes)`;
271
- return entry;
272
- }
273
- let text: string;
274
- try {
275
- text = fs.readFileSync(fullPath, "utf-8");
276
- } catch {
277
- return entry;
278
- }
279
- entry.sha256 = crypto.createHash("sha256").update(text, "utf-8").digest("hex").slice(0, 16);
280
- const extractor = EXTRACTORS[path.extname(fullPath)] || extractGeneric;
281
- try {
282
- Object.assign(entry, extractor(text));
283
- } catch (e) {
284
- entry.extraction_error = (e as Error).message.slice(0, 200);
285
- }
286
- entry.summary = summarise(entry);
287
- return entry;
288
- }
289
-
290
- function walk(dir: string, out: string[]): void {
291
- let entries: fs.Dirent[];
292
- try {
293
- entries = fs.readdirSync(dir, { withFileTypes: true });
294
- } catch {
295
- return;
296
- }
297
- for (const e of entries) {
298
- if (e.isDirectory()) {
299
- if (SKIP_DIRS.has(e.name) || e.name.startsWith(".")) continue;
300
- walk(path.join(dir, e.name), out);
301
- } else if (e.isFile()) {
302
- if (e.name.startsWith(".") && e.name !== ".env") continue;
303
- const ext = path.extname(e.name);
304
- if (!INDEX_EXT.has(ext) && e.name !== ".env") continue;
305
- out.push(path.join(dir, e.name));
306
- }
307
- }
308
- }
309
-
310
- function loadRaw(): IndexData {
311
- const p = indexPath();
312
- if (!fs.existsSync(p)) return { version: 1, files: {}, generated_at: 0 };
313
- try {
314
- return JSON.parse(fs.readFileSync(p, "utf-8")) as IndexData;
315
- } catch {
316
- return { version: 1, files: {}, generated_at: 0 };
317
- }
318
- }
319
-
320
- function saveRaw(data: IndexData): void {
321
- data.generated_at = Math.floor(Date.now() / 1000);
322
- fs.writeFileSync(indexPath(), JSON.stringify(data, null, 2), "utf-8");
61
+ return score;
323
62
  }
324
63
 
325
64
  export const ProjectIndex = {
@@ -370,19 +109,7 @@ export const ProjectIndex = {
370
109
  if (!q) return [];
371
110
  const hits: Array<[number, { path: string; summary: string; score: number; language: string }]> = [];
372
111
  for (const [rel, entry] of Object.entries(data.files)) {
373
- let score = 0;
374
- if (rel.toLowerCase().includes(q)) score += 10;
375
- for (const s of entry.symbols || []) {
376
- if (q === s.toLowerCase()) score += 8;
377
- else if (s.toLowerCase().includes(q)) score += 4;
378
- }
379
- for (const r of entry.routes || []) {
380
- if (`${r.path || ""} ${r.handler || ""}`.toLowerCase().includes(q)) score += 5;
381
- }
382
- if ((entry.summary || "").toLowerCase().includes(q)) score += 3;
383
- for (const imp of entry.imports || []) {
384
- if (imp.toLowerCase().includes(q)) score += 1;
385
- }
112
+ const score = scoreSearchEntry(q, rel, entry);
386
113
  if (score > 0) {
387
114
  hits.push([
388
115
  score,