tina4-nodejs 3.12.10 → 3.13.1

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.
@@ -17,6 +17,7 @@
17
17
  import * as fs from "node:fs";
18
18
  import * as path from "node:path";
19
19
  import * as os from "node:os";
20
+ import { spawnSync } from "node:child_process";
20
21
 
21
22
  // ── Types ─────────────────────────────────────────────────────
22
23
 
@@ -562,6 +563,217 @@ function redactEnv(key: string, value: string): string {
562
563
  return value;
563
564
  }
564
565
 
566
+ // ── Defensive write helpers (mirrors tina4-python tools.py) ──
567
+ //
568
+ // Five layered guards wrap `file_write` and `file_patch`:
569
+ // 1. agentLog — structured audit log at .tina4/agent.log + stderr
570
+ // 2. looksLikeProse — reject sentences-as-filenames (AI mishap)
571
+ // 3. normalizeCoderPath — rewrite bare routes/, orm/, ... to src/<dir>/
572
+ // 4. agentBackup — copy pre-write content to .tina4/backups/
573
+ // 5. truncation guard (inline in file_write) — refuse suspicious shrinkage
574
+
575
+ /**
576
+ * Append a structured line to `.tina4/agent.log` AND echo to stderr.
577
+ * Cheap — never blocks the caller on I/O failure.
578
+ */
579
+ function agentLog(projectRoot: string, category: string, message: string): void {
580
+ try {
581
+ const logDir = path.join(projectRoot, ".tina4");
582
+ if (!fs.existsSync(logDir)) {
583
+ fs.mkdirSync(logDir, { recursive: true });
584
+ }
585
+ const logPath = path.join(logDir, "agent.log");
586
+ const ts = new Date().toISOString().replace(/\..+/, "Z");
587
+ fs.appendFileSync(logPath, `${ts} [${category}] ${message}\n`, "utf-8");
588
+ } catch {
589
+ // logging must never fail the actual call
590
+ }
591
+ process.stderr.write(` [agent ${category}] ${message}\n`);
592
+ }
593
+
594
+ const SANE_PATH_SEGMENT = /^[A-Za-z0-9._\-]+$/;
595
+
596
+ /**
597
+ * Return an error string if the path looks like prose, else null.
598
+ * AI agents sometimes pass natural language as `path` to file_write
599
+ * and produce folders with prose names — catch the slip here.
600
+ */
601
+ function looksLikeProse(relPath: string): string | null {
602
+ if (!relPath || !relPath.trim()) {
603
+ return "path is empty";
604
+ }
605
+ if (relPath.length > 300) {
606
+ return `path too long (${relPath.length} chars); use a real filename`;
607
+ }
608
+ const badSequences = ["`", "\n", "\t", " ", " — ", " (", " [", "?", "*", "<", ">", "|"];
609
+ for (const bad of badSequences) {
610
+ if (relPath.includes(bad)) {
611
+ return `path contains illegal character sequence ${JSON.stringify(bad)} — looks like prose, not a filename`;
612
+ }
613
+ }
614
+ for (const seg of relPath.split("/")) {
615
+ if (!seg || seg === "." || seg === "..") {
616
+ continue;
617
+ }
618
+ if (seg.length > 80) {
619
+ return `path segment too long: ${JSON.stringify(seg.slice(0, 60))}… — use a short filename`;
620
+ }
621
+ if (!SANE_PATH_SEGMENT.test(seg)) {
622
+ return `path segment ${JSON.stringify(seg)} contains disallowed characters — stick to [A-Za-z0-9._-]`;
623
+ }
624
+ }
625
+ return null;
626
+ }
627
+
628
+ /**
629
+ * Rewrite bare top-level Tina4-conventional directories into their
630
+ * `src/<dir>/` canonical form. The framework's auto-discovery only
631
+ * scans `src/`, so a file at `templates/foo.twig` is dead weight —
632
+ * the framework never loads it. Mirrors Python's _normalize_coder_path.
633
+ */
634
+ function normalizeCoderPath(projectRoot: string, relPath: string): string {
635
+ const passthroughPrefixes = ["src/", "migrations/", "plan/", "tests/", "test/", ".tina4/"];
636
+ const passthroughFiles = new Set([
637
+ "app.py", "app.ts", "app.rb", "index.php",
638
+ "composer.json", "package.json", "Gemfile",
639
+ "pyproject.toml", "requirements.txt",
640
+ ".env", ".env.example",
641
+ ]);
642
+ if (passthroughPrefixes.some((p) => relPath.startsWith(p))) {
643
+ return relPath;
644
+ }
645
+ if (passthroughFiles.has(relPath)) {
646
+ return relPath;
647
+ }
648
+ const bareDirs = ["routes", "orm", "templates", "seeds", "controllers", "models", "middleware"];
649
+ for (const d of bareDirs) {
650
+ if (relPath.startsWith(`${d}/`)) {
651
+ const rewritten = `src/${relPath}`;
652
+ agentLog(projectRoot, "write.path_normalized", `${relPath} → ${rewritten}`);
653
+ return rewritten;
654
+ }
655
+ }
656
+ return relPath;
657
+ }
658
+
659
+ /**
660
+ * Copy `target` into `.tina4/backups/` with a timestamped name.
661
+ * Returns the relative backup path on success, null on failure.
662
+ */
663
+ function agentBackup(projectRoot: string, target: string): string | null {
664
+ try {
665
+ if (!fs.existsSync(target) || !fs.statSync(target).isFile()) {
666
+ return null;
667
+ }
668
+ const backupDir = path.join(projectRoot, ".tina4", "backups");
669
+ if (!fs.existsSync(backupDir)) {
670
+ fs.mkdirSync(backupDir, { recursive: true });
671
+ }
672
+ let rel = path.relative(projectRoot, target);
673
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
674
+ rel = path.basename(target);
675
+ }
676
+ const safe = rel.replace(/[/\\]/g, "__");
677
+ const ts = new Date().toISOString().replace(/:/g, "-").replace(/\..+/, "Z");
678
+ const backupName = `${safe}.${ts}.bak`;
679
+ const backupPath = path.join(backupDir, backupName);
680
+ fs.writeFileSync(backupPath, fs.readFileSync(target));
681
+ return `.tina4/backups/${backupName}`;
682
+ } catch (e) {
683
+ agentLog(projectRoot, "write.backup_failed", `${target}: ${(e as Error).message}`);
684
+ return null;
685
+ }
686
+ }
687
+
688
+ /**
689
+ * Try syntax-checking a freshly-written JS/TS module to catch
690
+ * hallucinated framework APIs and broken syntax BEFORE the next
691
+ * request hits the broken handler. Mirrors Python's _verify_python_import.
692
+ *
693
+ * Returns null on success, or the captured error string on failure.
694
+ *
695
+ * - Only checks files under `src/` with extensions .js / .ts / .mjs / .cjs.
696
+ * - Skips test files (*.test.{ts,js}, *.spec.{ts,js}) — they have their
697
+ * own loading patterns and would fail single-file type checking.
698
+ * - .js / .mjs / .cjs → `node --check <file>` (fast, exit 0 = OK).
699
+ * - .ts → `npx --no-install tsc --noEmit --allowJs --skipLibCheck <file>`.
700
+ * If tsc isn't available locally, returns null (don't block the write).
701
+ * - Uses spawnSync with 5-second timeout so a hung subprocess never
702
+ * blocks the MCP server.
703
+ */
704
+ function verifyNodeSyntax(absPath: string, relPath: string): string | null {
705
+ if (!relPath.startsWith("src/")) {
706
+ return null;
707
+ }
708
+ const ext = path.extname(relPath).toLowerCase();
709
+ if (![".js", ".ts", ".mjs", ".cjs"].includes(ext)) {
710
+ return null;
711
+ }
712
+ const base = path.basename(relPath);
713
+ if (/\.(test|spec)\.(ts|js|mjs|cjs)$/.test(base)) {
714
+ return null;
715
+ }
716
+
717
+ let cmd: string;
718
+ let args: string[];
719
+ if (ext === ".ts") {
720
+ cmd = "npx";
721
+ args = ["--no-install", "tsc", "--noEmit", "--allowJs", "--skipLibCheck", absPath];
722
+ } else {
723
+ cmd = "node";
724
+ args = ["--check", absPath];
725
+ }
726
+
727
+ let proc;
728
+ try {
729
+ proc = spawnSync(cmd, args, {
730
+ encoding: "utf-8",
731
+ timeout: 5000,
732
+ cwd: path.dirname(path.dirname(absPath)),
733
+ });
734
+ } catch (e) {
735
+ return `verification subprocess failed: ${(e as Error).message}`;
736
+ }
737
+
738
+ // npx may fail to find tsc — gracefully return null instead of blocking.
739
+ if (ext === ".ts" && (proc.error || proc.status === null || proc.status === 127)) {
740
+ return null;
741
+ }
742
+ if (proc.error) {
743
+ return `verification subprocess failed: ${proc.error.message}`;
744
+ }
745
+ if (proc.status === 0) {
746
+ return null;
747
+ }
748
+
749
+ // Errors land on stderr for `node --check`, stdout for tsc.
750
+ const raw = (ext === ".ts" ? (proc.stdout || "") + (proc.stderr || "") : (proc.stderr || "") + (proc.stdout || "")).trim();
751
+ // npx placeholder when tsc isn't installed locally — bail silently
752
+ // rather than block the write with a meaningless banner.
753
+ if (ext === ".ts" && raw.includes("This is not the tsc command")) {
754
+ return null;
755
+ }
756
+ if (!raw) {
757
+ return `syntax check failed (exit ${proc.status}, no output)`;
758
+ }
759
+ const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
760
+ if (lines.length === 0) {
761
+ return `syntax check failed (exit ${proc.status})`;
762
+ }
763
+ // Strip the absolute path prefix from the first meaningful line so the
764
+ // LLM sees a stable, project-relative error message.
765
+ const stripPath = (line: string): string => line.replace(absPath, relPath);
766
+ // For tsc: the first line is usually "src/foo.ts(3,5): error TS1109: ...".
767
+ // For node --check: the first line is the file path; the actual error is
768
+ // a later "SyntaxError: ..." line. Pick the most informative line.
769
+ for (const line of lines) {
770
+ if (/error|SyntaxError|TS\d+/i.test(line)) {
771
+ return stripPath(line);
772
+ }
773
+ }
774
+ return stripPath(lines[0]);
775
+ }
776
+
565
777
  /**
566
778
  * Register all 24 built-in dev tools on the given McpServer.
567
779
  */
@@ -734,15 +946,63 @@ export function registerDevTools(server: McpServer): void {
734
946
  server.registerTool(
735
947
  "file_write",
736
948
  (args) => {
737
- const p = safePath(projectRoot, args.path as string);
949
+ let rawPath = (args.path as string) || "";
950
+ // 1. Prose check first — before path resolution.
951
+ const proseErr = looksLikeProse(rawPath);
952
+ if (proseErr) {
953
+ return { error: `Invalid path ${JSON.stringify(rawPath)}: ${proseErr}` };
954
+ }
955
+ // 2. Coder-path normalization — rewrite bare top-level
956
+ // Tina4 dirs (templates/, routes/, ...) into src/.
957
+ rawPath = normalizeCoderPath(projectRoot, rawPath);
958
+ // 3. Safe path resolution (sandbox check — throws on escape).
959
+ const p = safePath(projectRoot, rawPath);
960
+ // 4. Compute old/new sizes for truncation guard + audit log.
961
+ const oldBytes = fs.existsSync(p) && fs.statSync(p).isFile()
962
+ ? fs.readFileSync(p)
963
+ : Buffer.alloc(0);
964
+ const oldSize = oldBytes.length;
965
+ const oldLines = (oldBytes.toString("utf-8").match(/\n/g) || []).length;
966
+ const content = args.content as string;
967
+ const newBytes = Buffer.from(content, "utf-8");
968
+ const newSize = newBytes.length;
969
+ const newLines = (content.match(/\n/g) || []).length;
970
+ const relPath = path.relative(projectRoot, p);
971
+
972
+ // 5. Truncation guard — refuse suspicious shrinkage on non-trivial files.
973
+ if (oldSize > 200 && newSize * 100 < oldSize * 30) {
974
+ const msg = `REFUSED ${relPath} (would shrink ${oldSize} → ${newSize} bytes / ` +
975
+ `${oldLines} → ${newLines} lines, looks truncated)`;
976
+ agentLog(projectRoot, "write.refused", msg);
977
+ return { error: msg, refused: true, old_bytes: oldSize, new_bytes: newSize };
978
+ }
979
+
980
+ // 6. Backup before overwrite.
981
+ const backupRel = oldSize > 0 ? agentBackup(projectRoot, p) : null;
982
+
983
+ // 7. Write.
738
984
  const dir = path.dirname(p);
739
985
  if (!fs.existsSync(dir)) {
740
986
  fs.mkdirSync(dir, { recursive: true });
741
987
  }
742
- const content = args.content as string;
743
988
  fs.writeFileSync(p, content, "utf-8");
744
- const relPath = path.relative(projectRoot, p);
745
- return { written: relPath, bytes: Buffer.byteLength(content, "utf-8") };
989
+
990
+ agentLog(projectRoot, "write.ok",
991
+ `${relPath} (${oldSize}B/${oldLines}L → ${newSize}B/${newLines}L, ` +
992
+ `backup: ${backupRel || "(no prior file)"})`);
993
+
994
+ const result: Record<string, unknown> = { written: relPath, bytes: newSize };
995
+ if (backupRel) {
996
+ result.backup = backupRel;
997
+ }
998
+ // 8. Post-write syntax verification — catch broken JS/TS inline
999
+ // so the LLM sees the error on its next turn.
1000
+ const importErr = verifyNodeSyntax(p, relPath);
1001
+ if (importErr) {
1002
+ result.import_error = importErr;
1003
+ agentLog(projectRoot, "write.import_failed", `${relPath}: ${importErr}`);
1004
+ }
1005
+ return result;
746
1006
  },
747
1007
  "Write or update a project file",
748
1008
  schemaFromParams([
@@ -1178,19 +1438,30 @@ export function registerDevTools(server: McpServer): void {
1178
1438
  "file_patch",
1179
1439
  (args) => {
1180
1440
  try {
1181
- const rel = (args.path as string) || "";
1441
+ let rel = (args.path as string) || "";
1182
1442
  const oldStr = (args.old_string as string) || "";
1183
1443
  const newStr = (args.new_string as string) || "";
1184
1444
  const count = (args.count as number) || 1;
1185
1445
  const projectRoot = path.resolve(process.cwd());
1446
+
1447
+ // 1. Prose check first — before path resolution.
1448
+ const proseErr = looksLikeProse(rel);
1449
+ if (proseErr) {
1450
+ return { error: `Invalid path ${JSON.stringify(rel)}: ${proseErr}` };
1451
+ }
1452
+ // 2. Coder-path normalization.
1453
+ rel = normalizeCoderPath(projectRoot, rel);
1454
+ // 3. Safe path resolution (sandbox check).
1186
1455
  const resolved = path.resolve(projectRoot, rel);
1187
1456
  if (!resolved.startsWith(projectRoot)) {
1188
1457
  return { error: `Path escapes project directory: ${rel}` };
1189
1458
  }
1459
+ // 4. Existence check.
1190
1460
  if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
1191
1461
  return { error: `File not found: ${rel}` };
1192
1462
  }
1193
1463
  const original = fs.readFileSync(resolved, "utf-8");
1464
+ // 5. Match-count guard.
1194
1465
  let occurrences = 0;
1195
1466
  let idx = -1;
1196
1467
  while ((idx = original.indexOf(oldStr, idx + 1)) !== -1) occurrences++;
@@ -1204,13 +1475,37 @@ export function registerDevTools(server: McpServer): void {
1204
1475
  }
1205
1476
  let updated = original;
1206
1477
  for (let i = 0; i < count; i++) updated = updated.replace(oldStr, newStr);
1478
+
1479
+ // 6. Backup before overwrite — same path as file_write so
1480
+ // recovery is uniform regardless of which tool touched the file.
1481
+ const backupRel = agentBackup(projectRoot, resolved);
1482
+
1483
+ // 7. Write.
1207
1484
  fs.writeFileSync(resolved, updated, "utf-8");
1208
1485
  try { loadPlan().recordAction("patched", rel); } catch { /* best-effort */ }
1209
- return {
1486
+
1487
+ const oldSize = Buffer.byteLength(original, "utf-8");
1488
+ const newSize = Buffer.byteLength(updated, "utf-8");
1489
+ agentLog(projectRoot, "patch.ok",
1490
+ `${rel} (replaced ${count}× old_string, ${oldSize}B → ${newSize}B, ` +
1491
+ `backup: ${backupRel || "(none)"})`);
1492
+
1493
+ const result: Record<string, unknown> = {
1210
1494
  patched: rel,
1211
1495
  replacements: count,
1212
- bytes: Buffer.byteLength(updated, "utf-8"),
1496
+ bytes: newSize,
1213
1497
  };
1498
+ if (backupRel) {
1499
+ result.backup = backupRel;
1500
+ }
1501
+ // 8. Post-patch syntax verification — same inline check as
1502
+ // file_write so a hallucinated edit surfaces immediately.
1503
+ const importErr = verifyNodeSyntax(resolved, rel);
1504
+ if (importErr) {
1505
+ result.import_error = importErr;
1506
+ agentLog(projectRoot, "patch.import_failed", `${rel}: ${importErr}`);
1507
+ }
1508
+ return result;
1214
1509
  } catch (e) {
1215
1510
  return { error: (e as Error).message };
1216
1511
  }
@@ -35,6 +35,9 @@ export interface PlanSummary {
35
35
  steps_total: number;
36
36
  steps_done: number;
37
37
  is_current: boolean;
38
+ /** Path relative to project root — lets the SPA open the right file
39
+ * regardless of which dir the plan came from (plan/ vs .tina4/plans/). */
40
+ path: string;
38
41
  }
39
42
 
40
43
  export interface ExecutionSummary {
@@ -158,25 +161,63 @@ function loadParsed(name: string): { path: string; plan: ParsedPlan } {
158
161
  // ── Plan namespace ────────────────────────────────────────────
159
162
 
160
163
  export const Plan = {
164
+ /**
165
+ * All plan files — merged from `plan/` (user-curated canonical) and
166
+ * `.tina4/plans/` (where the Rust supervisor's planner writes).
167
+ *
168
+ * Two directories exist because of a historic split: the framework
169
+ * treats `plan/` as the canonical project location, but the Rust agent's
170
+ * planner writes to `.tina4/plans/` (alongside other AI-state artefacts
171
+ * like chat history). Until those are unified we read both so plans
172
+ * created either way are discoverable. Dedup by filename when a plan
173
+ * exists in both — `plan/` wins on collision.
174
+ *
175
+ * Newest-first by filename (Rust planner uses unix-timestamp prefixes).
176
+ */
161
177
  listPlans(): PlanSummary[] {
162
- const d = planDir();
178
+ const root = projectRoot();
163
179
  const current = Plan.currentName() || "";
180
+
181
+ // Order matters for dedup: user-curated first.
182
+ const dirs: string[] = [planDir()];
183
+ const rustPlans = path.join(root, ".tina4", "plans");
184
+ if (fs.existsSync(rustPlans) && fs.statSync(rustPlans).isDirectory()) {
185
+ dirs.push(rustPlans);
186
+ }
187
+
188
+ const seen = new Set<string>();
164
189
  const out: PlanSummary[] = [];
165
- const entries = fs.readdirSync(d).filter((f) => f.endsWith(".md")).sort();
166
- for (const name of entries) {
167
- const full = path.join(d, name);
168
- if (!fs.statSync(full).isFile()) continue;
169
- const parsed = parse(fs.readFileSync(full, "utf-8"));
170
- const total = parsed.steps.length;
171
- const done = parsed.steps.filter((s) => s.done).length;
172
- out.push({
173
- name,
174
- title: parsed.title || name.replace(/\.md$/, ""),
175
- steps_total: total,
176
- steps_done: done,
177
- is_current: name === current,
178
- });
190
+ for (const dir of dirs) {
191
+ let entries: string[];
192
+ try {
193
+ entries = fs.readdirSync(dir).filter((f) => f.endsWith(".md")).sort();
194
+ } catch {
195
+ continue; // dir disappeared between exists() and readdir — skip
196
+ }
197
+ for (const name of entries) {
198
+ if (seen.has(name)) continue; // plan/ wins over .tina4/plans/ on name clash
199
+ const full = path.join(dir, name);
200
+ let stat;
201
+ try { stat = fs.statSync(full); } catch { continue; }
202
+ if (!stat.isFile()) continue;
203
+ seen.add(name);
204
+ const parsed = parse(fs.readFileSync(full, "utf-8"));
205
+ const total = parsed.steps.length;
206
+ const done = parsed.steps.filter((s) => s.done).length;
207
+ out.push({
208
+ name,
209
+ title: parsed.title || name.replace(/\.md$/, ""),
210
+ steps_total: total,
211
+ steps_done: done,
212
+ is_current: name === current,
213
+ // Relative path from project root — lets the SPA open the right
214
+ // file in the editor regardless of source dir.
215
+ path: path.relative(root, full),
216
+ });
217
+ }
179
218
  }
219
+ // Newest first by name (filenames start with unix timestamps).
220
+ out.sort((a, b) => (a.name < b.name ? 1 : a.name > b.name ? -1 : 0));
180
221
  return out;
181
222
  },
182
223
 
@@ -3,7 +3,16 @@ import type { Tina4Request, UploadedFile } from "./types.js";
3
3
 
4
4
  export function createRequest(req: IncomingMessage): Tina4Request {
5
5
  const tReq = req as Tina4Request;
6
- const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
6
+
7
+ // Resolve scheme + host honouring proxy headers — parity with PHP/Python/Ruby.
8
+ const xfProto = req.headers["x-forwarded-proto"];
9
+ const proto = (Array.isArray(xfProto) ? xfProto[0] : xfProto)
10
+ ?? ((req.socket as { encrypted?: boolean })?.encrypted ? "https" : "http");
11
+ const xfHost = req.headers["x-forwarded-host"];
12
+ const host = (Array.isArray(xfHost) ? xfHost[0] : xfHost)
13
+ ?? (req.headers.host ?? "localhost");
14
+
15
+ const url = new URL(req.url ?? "/", `${proto}://${host}`);
7
16
  const query: Record<string, string> = {};
8
17
  for (const [key, value] of url.searchParams) {
9
18
  query[key] = value;
@@ -11,6 +20,13 @@ export function createRequest(req: IncomingMessage): Tina4Request {
11
20
 
12
21
  tReq.params = {};
13
22
  tReq.query = query;
23
+ // Path, query string, and full URL — same shape across all four frameworks.
24
+ // `path` is the URL path only; `queryString` is the raw query without "?".
25
+ // `url` is overridden from Node's IncomingMessage.url (path+query) to the
26
+ // full absolute URL — parity with PHP/Python/Ruby.
27
+ tReq.path = url.pathname;
28
+ tReq.queryString = url.search.replace(/^\?/, "");
29
+ tReq.url = url.toString();
14
30
  tReq.body = undefined;
15
31
  tReq.files = {};
16
32
  tReq.contentType = (req.headers["content-type"] ?? "") as string;
@@ -1,13 +1,27 @@
1
- import { readdirSync, statSync } from "node:fs";
1
+ import { readdirSync, statSync, mkdirSync, writeFileSync, existsSync } from "node:fs";
2
2
  import { join, relative, basename, extname } from "node:path";
3
3
  import type { RouteDefinition, RouteHandler, RouteMeta } from "./types.js";
4
4
 
5
5
  const VALID_METHODS = new Set(["get", "post", "put", "delete", "patch"]);
6
6
 
7
+ /**
8
+ * Files already discovered by a prior scan. Lets rediscoverRoutes() pick up
9
+ * only the freshly-added files without double-registering anything that was
10
+ * already loaded.
11
+ */
12
+ const _seenFiles = new Set<string>();
13
+
14
+ /** The last directory passed to discoverRoutes() — used by rediscoverRoutes(). */
15
+ let _lastRoutesDir = "";
16
+
7
17
  export async function discoverRoutes(routesDir: string): Promise<RouteDefinition[]> {
18
+ _lastRoutesDir = routesDir;
8
19
  const definitions: RouteDefinition[] = [];
9
20
  const files = walkDir(routesDir);
10
21
 
22
+ let routeFileCount = 0;
23
+ let registeredFromThisScan = 0;
24
+
11
25
  for (const filePath of files) {
12
26
  const ext = extname(filePath);
13
27
  if (ext !== ".ts" && ext !== ".js") continue;
@@ -15,6 +29,10 @@ export async function discoverRoutes(routesDir: string): Promise<RouteDefinition
15
29
  const name = basename(filePath, ext).toLowerCase();
16
30
  if (!VALID_METHODS.has(name)) continue;
17
31
 
32
+ routeFileCount++;
33
+
34
+ if (_seenFiles.has(filePath)) continue;
35
+
18
36
  const method = name.toUpperCase();
19
37
  const relativePath = relative(routesDir, filePath);
20
38
  const pattern = filePathToPattern(relativePath);
@@ -34,14 +52,64 @@ export async function discoverRoutes(routesDir: string): Promise<RouteDefinition
34
52
  const template: string | undefined = typeof mod.template === "string" ? mod.template : undefined;
35
53
 
36
54
  definitions.push({ method, pattern, handler, filePath, meta, template });
55
+ _seenFiles.add(filePath);
56
+ registeredFromThisScan++;
37
57
  } catch (err) {
38
58
  console.error(` Error loading route ${relativePath}:`, err);
59
+ recordBrokenImport(filePath, err as Error);
39
60
  }
40
61
  }
41
62
 
63
+ // Zero-routes warning: src/routes/ has method-named files but none of them
64
+ // produced a route this scan. Could mean every file failed to import, or
65
+ // the handler exports are missing — both situations the user wants to know
66
+ // about loudly. Only fires on the first scan when nothing came back.
67
+ if (routeFileCount > 0 && registeredFromThisScan === 0 && _seenFiles.size === 0) {
68
+ console.warn(
69
+ ` Warning: ${routeFileCount} method-named file(s) in ${routesDir} but no routes registered. ` +
70
+ `Each route file must \`export default async function (req, res) { ... }\`.`,
71
+ );
72
+ }
73
+
42
74
  return definitions;
43
75
  }
44
76
 
77
+ /**
78
+ * Re-run the most recent route scan — called by POST /__dev/api/reload so a
79
+ * newly-added file in src/routes/ registers without a server restart. Already
80
+ * loaded files are skipped. No-op if discoverRoutes() has never been called.
81
+ */
82
+ export async function rediscoverRoutes(): Promise<RouteDefinition[]> {
83
+ if (!_lastRoutesDir) return [];
84
+ return discoverRoutes(_lastRoutesDir);
85
+ }
86
+
87
+ /** Test-only: reset the seen-files state so tests can replay the same dir. */
88
+ export function _resetRouteDiscovery(): void {
89
+ _seenFiles.clear();
90
+ _lastRoutesDir = "";
91
+ }
92
+
93
+ /**
94
+ * Write a .broken sentinel so /health and the dev dashboard surface auto-discover
95
+ * failures rather than swallowing them into a console line nobody reads.
96
+ */
97
+ function recordBrokenImport(filePath: string, error: Error): void {
98
+ try {
99
+ const brokenDir = join(process.cwd(), "data", ".broken");
100
+ if (!existsSync(brokenDir)) mkdirSync(brokenDir, { recursive: true });
101
+ const slug = filePath.replace(/[/\\]/g, "_");
102
+ const payload = JSON.stringify({
103
+ type: "auto_discover_failure",
104
+ file: filePath,
105
+ error: `${error.name}: ${error.message}`,
106
+ }, null, 2);
107
+ writeFileSync(join(brokenDir, `discover_${slug}.broken`), payload);
108
+ } catch {
109
+ // .broken write itself failed — the original error is already logged.
110
+ }
111
+ }
112
+
45
113
  function filePathToPattern(relativePath: string): string {
46
114
  // Remove the filename (get.ts, post.ts, etc.) to get the directory path
47
115
  // Normalise backslashes for Windows compatibility