tina4-nodejs 3.11.14 → 3.11.17

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,440 @@
1
+ /**
2
+ * Project index — lightweight, persistent "where is what" map.
3
+ *
4
+ * Ported from tina4_python/dev_admin/project_index.py (master reference).
5
+ *
6
+ * Storage: .tina4/project_index.json at the project root.
7
+ * Freshness: lazy-refreshes on read via mtime compare — no watchers.
8
+ * Extractors: per-language symbol extraction (TS/JS, Twig/HTML, SQL, Markdown,
9
+ * Python) using regex. No LLM involvement — pure static analysis.
10
+ */
11
+
12
+ import * as fs from "node:fs";
13
+ 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;
31
+
32
+ export interface FileRoute {
33
+ method: string;
34
+ path: string;
35
+ handler: string;
36
+ }
37
+
38
+ export interface FileEntry {
39
+ path?: string;
40
+ size?: number;
41
+ mtime?: number;
42
+ language?: string;
43
+ sha256?: string;
44
+ skipped?: string;
45
+ extraction_error?: string;
46
+ summary?: string;
47
+ symbols?: string[];
48
+ imports?: string[];
49
+ routes?: FileRoute[];
50
+ docstring?: string;
51
+ exports?: string[];
52
+ extends?: string[];
53
+ blocks?: string[];
54
+ includes?: string[];
55
+ creates?: string[];
56
+ alters?: string[];
57
+ title?: string;
58
+ sections?: string[];
59
+ first_line?: string;
60
+ error?: string;
61
+ }
62
+
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]);
102
+ }
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
+ }
110
+ }
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: "" });
189
+ }
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");
323
+ }
324
+
325
+ export const ProjectIndex = {
326
+ refresh(): { added: number; updated: number; removed: number; total: number; path: string } {
327
+ const data = loadRaw();
328
+ const files = data.files || {};
329
+ let added = 0;
330
+ let updated = 0;
331
+ const seen = new Set<string>();
332
+ const root = projectRoot();
333
+ const found: string[] = [];
334
+ walk(root, found);
335
+ for (const fp of found) {
336
+ const rel = path.relative(root, fp);
337
+ seen.add(rel);
338
+ let mtime: number;
339
+ try {
340
+ mtime = Math.floor(fs.statSync(fp).mtimeMs / 1000);
341
+ } catch {
342
+ continue;
343
+ }
344
+ const existing = files[rel];
345
+ if (existing && existing.mtime === mtime) continue;
346
+ files[rel] = extract(fp);
347
+ if (existing) updated++;
348
+ else added++;
349
+ }
350
+ const removed: string[] = [];
351
+ for (const k of Object.keys(files)) {
352
+ if (!seen.has(k)) removed.push(k);
353
+ }
354
+ for (const k of removed) delete files[k];
355
+ data.files = files;
356
+ saveRaw(data);
357
+ return {
358
+ added,
359
+ updated,
360
+ removed: removed.length,
361
+ total: Object.keys(files).length,
362
+ path: path.relative(root, indexPath()),
363
+ };
364
+ },
365
+
366
+ search(query: string, limit = 20): Array<{ path: string; summary: string; score: number; language: string }> {
367
+ ProjectIndex.refresh();
368
+ const data = loadRaw();
369
+ const q = (query || "").toLowerCase().trim();
370
+ if (!q) return [];
371
+ const hits: Array<[number, { path: string; summary: string; score: number; language: string }]> = [];
372
+ 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
+ }
386
+ if (score > 0) {
387
+ hits.push([
388
+ score,
389
+ {
390
+ path: rel,
391
+ summary: entry.summary || "",
392
+ score,
393
+ language: entry.language || "",
394
+ },
395
+ ]);
396
+ }
397
+ }
398
+ hits.sort((a, b) => b[0] - a[0]);
399
+ return hits.slice(0, Math.max(1, limit)).map((h) => h[1]);
400
+ },
401
+
402
+ fileEntry(relPath: string): FileEntry {
403
+ ProjectIndex.refresh();
404
+ const data = loadRaw();
405
+ return data.files[relPath] || { error: `Not in index: ${relPath}` };
406
+ },
407
+
408
+ overview(): Record<string, unknown> {
409
+ ProjectIndex.refresh();
410
+ const data = loadRaw();
411
+ const files = data.files;
412
+ const langs: Record<string, number> = {};
413
+ let routeCount = 0;
414
+ let modelCount = 0;
415
+ for (const entry of Object.values(files)) {
416
+ const lang = entry.language || "other";
417
+ langs[lang] = (langs[lang] || 0) + 1;
418
+ routeCount += (entry.routes || []).length;
419
+ const p = entry.path || "";
420
+ if (
421
+ (p.startsWith("src/orm/") || p.startsWith("src/models/")) &&
422
+ (((entry.symbols || []).length > 0) || ((entry.exports || []).length > 0))
423
+ ) {
424
+ modelCount++;
425
+ }
426
+ }
427
+ const recent = Object.values(files)
428
+ .map((e) => ({ path: e.path, summary: e.summary || "", mtime: e.mtime || 0 }))
429
+ .sort((a, b) => (b.mtime || 0) - (a.mtime || 0))
430
+ .slice(0, 10);
431
+ return {
432
+ total_files: Object.keys(files).length,
433
+ by_language: langs,
434
+ routes_declared: routeCount,
435
+ orm_models: modelCount,
436
+ recently_changed: recent,
437
+ index_generated_at: data.generated_at || 0,
438
+ };
439
+ },
440
+ };
@@ -867,6 +867,35 @@ function evalTest(
867
867
 
868
868
  // ── Filters ────────────────────────────────────────────────────
869
869
 
870
+ /**
871
+ * Split `"first.groupSummary"` into `["first", "groupSummary"]` so a
872
+ * filter segment followed by property access — `{{ x | first.name }}`
873
+ * — can apply the filter then traverse the path on the result. Returns
874
+ * `[fname, ""]` when no structural `.` is present.
875
+ *
876
+ * The split point sits outside parens/brackets/braces and quotes so
877
+ * filter args like `round(1.5)` or `date("Y.m.d")` don't false-trigger.
878
+ * Parity with tina4-python and tina4-php.
879
+ */
880
+ function splitFilterNameAndPath(fname: string): [string, string] {
881
+ let depth = 0;
882
+ let inQ: string | null = null;
883
+ for (let i = 0; i < fname.length; i++) {
884
+ const ch = fname[i];
885
+ if (inQ !== null) {
886
+ if (ch === inQ && (i === 0 || fname[i - 1] !== "\\")) inQ = null;
887
+ continue;
888
+ }
889
+ if (ch === '"' || ch === "'") { inQ = ch; continue; }
890
+ if (ch === "(" || ch === "[" || ch === "{") { depth++; continue; }
891
+ if (ch === ")" || ch === "]" || ch === "}") { depth--; continue; }
892
+ if (ch === "." && depth === 0) {
893
+ return [fname.slice(0, i), fname.slice(i + 1)];
894
+ }
895
+ }
896
+ return [fname, ""];
897
+ }
898
+
870
899
  function parseFilterChain(expr: string): [string, [string, string[]][]] {
871
900
  // Check cache first
872
901
  const cached = filterChainCache.get(expr);
@@ -1738,6 +1767,33 @@ export class Frond {
1738
1767
  let value = evalExpr(varName, context);
1739
1768
  for (const [fname, args] of filters) {
1740
1769
  if (fname === "raw" || fname === "safe") continue;
1770
+
1771
+ // Filter + property-access chain: `first.groupSummary` — apply
1772
+ // the filter, then traverse the path on the result via a
1773
+ // synthetic context so evalExpr's dotted resolution does the
1774
+ // work. Parity with tina4-python + tina4-php. `first` and
1775
+ // `last` are inlined because they're in the fast-path switch
1776
+ // rather than `this.filters`.
1777
+ const [realFname, tailPath] = splitFilterNameAndPath(fname);
1778
+ if (tailPath) {
1779
+ let applied = false;
1780
+ if (realFname === "first") {
1781
+ value = Array.isArray(value) ? value[0] ?? null : null;
1782
+ applied = true;
1783
+ } else if (realFname === "last") {
1784
+ value = Array.isArray(value) ? value[value.length - 1] ?? null : null;
1785
+ applied = true;
1786
+ } else if (this.filters[realFname]) {
1787
+ value = this.filters[realFname](value, ...args);
1788
+ applied = true;
1789
+ }
1790
+ if (applied) {
1791
+ value = evalExpr("__frondFilterTmp." + tailPath,
1792
+ { __frondFilterTmp: value });
1793
+ continue;
1794
+ }
1795
+ }
1796
+
1741
1797
  const fn = this.filters[fname];
1742
1798
  if (fn) {
1743
1799
  value = fn(value, ...args);
@@ -1802,6 +1858,36 @@ export class Frond {
1802
1858
  }
1803
1859
  }
1804
1860
 
1861
+ // Filter + property-access chain: `first.groupSummary` — apply
1862
+ // the filter, then traverse the path on the result via evalExpr.
1863
+ // Done BEFORE the inline fast-path so `items|first.name` works
1864
+ // whether or not `first` is in the fast-path list.
1865
+ //
1866
+ // We inline `first` and `last` here because they're defined by
1867
+ // the fast-path switch below, not in `this.filters` — without
1868
+ // this explicit branch, the chain would fall through and we'd
1869
+ // return the unfiltered array. All other registered filters
1870
+ // route through `this.filters[realFname]`.
1871
+ const [realFname, tailPath] = splitFilterNameAndPath(fname);
1872
+ if (tailPath) {
1873
+ let applied = false;
1874
+ if (realFname === "first") {
1875
+ value = Array.isArray(value) ? value[0] ?? null : null;
1876
+ applied = true;
1877
+ } else if (realFname === "last") {
1878
+ value = Array.isArray(value) ? value[value.length - 1] ?? null : null;
1879
+ applied = true;
1880
+ } else if (this.filters[realFname]) {
1881
+ value = this.filters[realFname](value, ...args);
1882
+ applied = true;
1883
+ }
1884
+ if (applied) {
1885
+ value = evalExpr("__frondFilterTmp." + tailPath,
1886
+ { __frondFilterTmp: value });
1887
+ continue;
1888
+ }
1889
+ }
1890
+
1805
1891
  // Inline fast-path for common no-arg filters — avoids generic dispatch
1806
1892
  if (args.length === 0) {
1807
1893
  switch (fname) {