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.
- package/CLAUDE.md +3 -3
- package/README.md +2 -2
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +3181 -3051
- package/packages/cli/src/commands/generate.ts +33 -22
- package/packages/cli/src/commands/lint.ts +77 -111
- package/packages/core/dist/index.js +3090 -2952
- package/packages/core/src/.tina4-metrics.json +15004 -0
- package/packages/core/src/aiClient.ts +199 -161
- package/packages/core/src/dispatchPipeline.ts +65 -67
- package/packages/core/src/docs.ts +52 -544
- package/packages/core/src/docsParser.ts +270 -0
- package/packages/core/src/docsScanner.ts +121 -0
- package/packages/core/src/docsSignatures.ts +165 -0
- package/packages/core/src/index.ts +2 -0
- package/packages/core/src/logger.ts +68 -82
- package/packages/core/src/mcp.ts +32 -60
- package/packages/core/src/messenger.ts +136 -157
- package/packages/core/src/middleware.ts +56 -60
- package/packages/core/src/plan.ts +78 -70
- package/packages/core/src/projectIndex.ts +15 -288
- package/packages/core/src/projectIndexExtractors.ts +126 -0
- package/packages/core/src/projectIndexStorage.ts +122 -0
- package/packages/core/src/push.ts +281 -0
- package/packages/core/src/server.ts +182 -183
- package/packages/frond/dist/index.js +607 -770
- package/packages/frond/src/engine.ts +670 -818
- package/packages/orm/dist/index.js +3100 -2965
- package/packages/orm/src/adapters/mongodb.ts +99 -144
- package/packages/orm/src/baseModel.ts +429 -515
- package/packages/orm/src/fakeData.ts +73 -61
- package/packages/orm/src/migration.ts +96 -126
- package/packages/orm/src/seeder.ts +6 -238
- package/packages/orm/src/seederTable.ts +101 -0
- package/packages/orm/src/seederTypes.ts +14 -0
- package/packages/orm/src/validation.ts +97 -80
- package/types/core/src/aiClient.d.ts +5 -0
- package/types/core/src/docsParser.d.ts +28 -0
- package/types/core/src/docsScanner.d.ts +1 -0
- package/types/core/src/docsSignatures.d.ts +11 -0
- package/types/core/src/index.d.ts +2 -0
- package/types/core/src/messenger.d.ts +8 -0
- package/types/core/src/projectIndexExtractors.d.ts +3 -0
- package/types/core/src/projectIndexStorage.d.ts +13 -0
- package/types/core/src/push.d.ts +45 -0
- package/types/frond/src/engine.d.ts +25 -0
- package/types/orm/src/fakeData.d.ts +3 -0
- package/types/orm/src/seeder.d.ts +3 -89
- package/types/orm/src/seederTable.d.ts +9 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
return reject("
|
|
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
|
-
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|