@mandujs/core 0.54.5 → 0.54.7

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.
@@ -15,10 +15,11 @@
15
15
  * 4. Value literals in `DEFAULT` clauses flow through `resolveDefault`
16
16
  * which handles quote escaping. `kind: "sql"` is the explicit
17
17
  * escape hatch — caller's responsibility.
18
- * 5. Unsupported changes (v1 scope — `alter-column-type`) emit a
19
- * `-- TODO:` comment block + a no-op `SELECT 1;` so the generated
20
- * migration still parses and Agent C's runner can record an
21
- * "applied but manual" row.
18
+ * 5. Unsupported changes (v1 scope — `alter-column-type`, and some
19
+ * dialect-specific ALTER gaps) emit a `-- TODO:` comment block plus
20
+ * a deliberate failing sentinel statement. The generated migration
21
+ * is reviewable/editable, but `mandu db apply` will not record it
22
+ * until the operator replaces the TODO with real SQL.
22
23
  *
23
24
  * Determinism:
24
25
  * - `emitCreateTable` emits columns in `DdlFieldDef` array order — the
@@ -338,15 +339,16 @@ export function emitChange(change: Change, provider: SqlProvider): string {
338
339
  // Change emitters (internal — called only via emitChange dispatch).
339
340
  // =====================================================================
340
341
 
341
- function emitAddColumn(
342
- resourceName: string,
343
- field: DdlFieldDef,
344
- provider: SqlProvider,
345
- ): string {
346
- const table = quoteIdent(resourceName, provider);
347
- const columnDef = emitColumnDef(field, provider);
348
- const addColumn = `ALTER TABLE ${table} ADD COLUMN ${columnDef};`;
349
- if (!field.indexed || field.unique || field.primary) return addColumn;
342
+ function emitAddColumn(
343
+ resourceName: string,
344
+ field: DdlFieldDef,
345
+ provider: SqlProvider,
346
+ ): string {
347
+ validateAddColumn(resourceName, field, provider);
348
+ const table = quoteIdent(resourceName, provider);
349
+ const columnDef = emitColumnDef(field, provider);
350
+ const addColumn = `ALTER TABLE ${table} ADD COLUMN ${columnDef};`;
351
+ if (!field.indexed || field.unique || field.primary) return addColumn;
350
352
  return [
351
353
  addColumn,
352
354
  emitCreateIndex(
@@ -356,8 +358,85 @@ function emitAddColumn(
356
358
  false,
357
359
  provider,
358
360
  ),
359
- ].join("\n");
360
- }
361
+ ].join("\n");
362
+ }
363
+
364
+ function validateAddColumn(
365
+ resourceName: string,
366
+ field: DdlFieldDef,
367
+ provider: SqlProvider,
368
+ ): void {
369
+ if (provider !== "sqlite") return;
370
+
371
+ if (field.primary) {
372
+ throw new Error(
373
+ `SQLite cannot add PRIMARY KEY column "${field.name}" to existing table "${resourceName}" via ALTER TABLE. ` +
374
+ `Create a manual table-rebuild migration instead.`,
375
+ );
376
+ }
377
+
378
+ if (field.unique) {
379
+ throw new Error(
380
+ `SQLite cannot add UNIQUE column "${field.name}" to existing table "${resourceName}" via ALTER TABLE. ` +
381
+ `Add a nullable/non-unique column first, backfill it, then create a unique index manually.`,
382
+ );
383
+ }
384
+
385
+ const defaultIssue = sqliteAddColumnDefaultIssue(field);
386
+ if (defaultIssue) {
387
+ throw new Error(
388
+ `SQLite cannot add column "${field.name}" to existing table "${resourceName}" with this DEFAULT: ` +
389
+ `${defaultIssue} Use a scalar literal default or write a manual backfill migration.`,
390
+ );
391
+ }
392
+
393
+ if (field.nullable) return;
394
+ if (field.default !== undefined && !isSqliteNullDefault(field.default)) return;
395
+
396
+ throw new Error(
397
+ `SQLite cannot add required column "${field.name}" to existing table "${resourceName}" without a non-NULL constant DEFAULT. ` +
398
+ `Add a scalar default, set required:false, or write a manual backfill migration.`,
399
+ );
400
+ }
401
+
402
+ function sqliteAddColumnDefaultIssue(field: DdlFieldDef): string | null {
403
+ const def = field.default;
404
+ if (!def) return null;
405
+ if (def.kind === "now") {
406
+ return `default "now" maps to CURRENT_TIMESTAMP, which SQLite rejects in ADD COLUMN.`;
407
+ }
408
+ if (def.kind !== "sql") return null;
409
+
410
+ const expr = def.expr.trim();
411
+ if (/^CURRENT_(?:TIME|DATE|TIMESTAMP)\b/i.test(expr)) {
412
+ return `SQLite rejects CURRENT_TIME/CURRENT_DATE/CURRENT_TIMESTAMP in ADD COLUMN.`;
413
+ }
414
+ if (/\b[A-Za-z_][A-Za-z0-9_]*\s*\(/.test(expr)) {
415
+ return `SQLite rejects non-constant function defaults in ADD COLUMN.`;
416
+ }
417
+ if (!isSqliteAddColumnConstantSqlDefault(expr)) {
418
+ return `SQLite ADD COLUMN only accepts literal constant defaults; raw SQL expression ${JSON.stringify(expr)} is not safe to emit automatically.`;
419
+ }
420
+ return null;
421
+ }
422
+
423
+ function isSqliteNullDefault(def: NonNullable<DdlFieldDef["default"]>): boolean {
424
+ return def.kind === "null" || (def.kind === "sql" && /^NULL$/i.test(def.expr.trim()));
425
+ }
426
+
427
+ function isSqliteAddColumnConstantSqlDefault(expr: string): boolean {
428
+ let value = expr.trim();
429
+ while (value.startsWith("(") && value.endsWith(")")) {
430
+ value = value.slice(1, -1).trim();
431
+ }
432
+ return (
433
+ /^NULL$/i.test(value) ||
434
+ /^(?:TRUE|FALSE)$/i.test(value) ||
435
+ /^[+-]?(?:\d+|\d+\.\d+|\.\d+)(?:e[+-]?\d+)?$/i.test(value) ||
436
+ /^'(?:''|[^'])*'$/.test(value) ||
437
+ /^X'(?:[0-9a-f]{2})*'$/i.test(value)
438
+ );
439
+ }
361
440
 
362
441
  /**
363
442
  * DROP COLUMN.
@@ -383,8 +462,8 @@ function emitDropColumn(
383
462
  *
384
463
  * Output: a multi-line SQL comment block naming the resource + field +
385
464
  * fromType → toType, followed by the literal TODO message and a no-op
386
- * `SELECT 1;` so the migration runner (Agent C) parses and advances past
387
- * this statement.
465
+ * a deliberate failing sentinel so the migration runner refuses to mark
466
+ * this TODO as applied until the operator edits the migration manually.
388
467
  */
389
468
  function emitAlterColumnTypeStub(
390
469
  resourceName: string,
@@ -398,11 +477,13 @@ function emitAlterColumnTypeStub(
398
477
  `-- from: ${fromType}`,
399
478
  `-- to: ${toType}`,
400
479
  `-- TODO: Mandu does not auto-generate ALTER COLUMN TYPE in v1.`,
401
- `-- Please write the migration manually, then re-run \`mandu db apply\`.`,
402
- `-- ================================================================`,
403
- `SELECT 1;`,
404
- ].join("\n");
405
- }
480
+ `-- Please write the migration manually, then re-run \`mandu db apply\`.`,
481
+ `-- ================================================================`,
482
+ manualMigrationRequiredStatement(
483
+ `Mandu cannot auto-generate ALTER COLUMN TYPE for ${resourceName}.${fieldName}`,
484
+ ),
485
+ ].join("\n");
486
+ }
406
487
 
407
488
  function emitAlterColumnNullable(
408
489
  resourceName: string,
@@ -419,30 +500,34 @@ function emitAlterColumnNullable(
419
500
  : `ALTER TABLE ${table} ALTER COLUMN ${col} SET NOT NULL;`;
420
501
  }
421
502
  if (provider === "sqlite") {
422
- // SQLite cannot toggle NOT NULL on an existing column without a full
423
- // table recreate. Emit a stub so the user handles it manually.
424
- return [
425
- `-- ================================================================`,
426
- `-- Nullability change: ${resourceName}.${fieldName} → ${nullable ? "NULL" : "NOT NULL"}`,
427
- `-- TODO: SQLite cannot toggle NOT NULL in place. Recreate the table`,
428
- `-- manually (CREATE new, INSERT SELECT, DROP old, RENAME) and re-run.`,
429
- `-- ================================================================`,
430
- `SELECT 1;`,
431
- ].join("\n");
432
- }
503
+ // SQLite cannot toggle NOT NULL on an existing column without a full
504
+ // table recreate. Emit a failing stub so the user handles it manually.
505
+ return [
506
+ `-- ================================================================`,
507
+ `-- Nullability change: ${resourceName}.${fieldName} → ${nullable ? "NULL" : "NOT NULL"}`,
508
+ `-- TODO: SQLite cannot toggle NOT NULL in place. Recreate the table`,
509
+ `-- manually (CREATE new, INSERT SELECT, DROP old, RENAME) and re-run.`,
510
+ `-- ================================================================`,
511
+ manualMigrationRequiredStatement(
512
+ `SQLite nullability change requires manual table rebuild for ${resourceName}.${fieldName}`,
513
+ ),
514
+ ].join("\n");
515
+ }
433
516
  // MySQL requires the full column spec; we cannot reconstruct it here.
434
517
  // Emit a stub — Agent B's diff emits `alter-column-nullable` only when
435
518
  // everything else matches, so this is narrow-scope.
436
519
  return [
437
520
  `-- ================================================================`,
438
521
  `-- Nullability change: ${resourceName}.${fieldName} → ${nullable ? "NULL" : "NOT NULL"}`,
439
- `-- TODO: MySQL MODIFY COLUMN requires the full column type; Mandu v1`,
440
- `-- cannot emit this automatically. Please edit this migration to use`,
441
- `-- \`ALTER TABLE ${resourceName} MODIFY COLUMN ${fieldName} <TYPE> ${nullable ? "NULL" : "NOT NULL"}\``,
442
- `-- ================================================================`,
443
- `SELECT 1;`,
444
- ].join("\n");
445
- }
522
+ `-- TODO: MySQL MODIFY COLUMN requires the full column type; Mandu v1`,
523
+ `-- cannot emit this automatically. Please edit this migration to use`,
524
+ `-- \`ALTER TABLE ${resourceName} MODIFY COLUMN ${fieldName} <TYPE> ${nullable ? "NULL" : "NOT NULL"}\``,
525
+ `-- ================================================================`,
526
+ manualMigrationRequiredStatement(
527
+ `MySQL nullability change requires manual MODIFY COLUMN for ${resourceName}.${fieldName}`,
528
+ ),
529
+ ].join("\n");
530
+ }
446
531
 
447
532
  function emitAlterColumnDefault(
448
533
  resourceName: string,
@@ -458,15 +543,17 @@ function emitAlterColumnDefault(
458
543
  : `ALTER TABLE ${table} ALTER COLUMN ${col} DROP DEFAULT;`;
459
544
  }
460
545
  if (provider === "sqlite") {
461
- // Same constraint as nullability — SQLite needs a table recreate.
462
- return [
463
- `-- ================================================================`,
464
- `-- Default change: ${resourceName}.${fieldName}`,
465
- `-- TODO: SQLite cannot ALTER DEFAULT in place. Recreate the table.`,
466
- `-- ================================================================`,
467
- `SELECT 1;`,
468
- ].join("\n");
469
- }
546
+ // Same constraint as nullability — SQLite needs a table recreate.
547
+ return [
548
+ `-- ================================================================`,
549
+ `-- Default change: ${resourceName}.${fieldName}`,
550
+ `-- TODO: SQLite cannot ALTER DEFAULT in place. Recreate the table.`,
551
+ `-- ================================================================`,
552
+ manualMigrationRequiredStatement(
553
+ `SQLite default change requires manual table rebuild for ${resourceName}.${fieldName}`,
554
+ ),
555
+ ].join("\n");
556
+ }
470
557
  // MySQL — ALTER COLUMN ... SET DEFAULT / DROP DEFAULT is actually supported.
471
558
  return def
472
559
  ? `ALTER TABLE ${table} ALTER COLUMN ${col} SET DEFAULT ${resolveDefault(def, provider)};`
@@ -510,7 +597,7 @@ function emitRenameTable(
510
597
  return `ALTER TABLE ${from} RENAME TO ${to};`;
511
598
  }
512
599
 
513
- function emitRenameColumn(
600
+ function emitRenameColumn(
514
601
  resourceName: string,
515
602
  oldName: string,
516
603
  newName: string,
@@ -522,7 +609,15 @@ function emitRenameColumn(
522
609
  // All three dialects use `ALTER TABLE ... RENAME COLUMN ... TO ...` in
523
610
  // their modern versions (PG >=9.2, MySQL >=8.0.3, SQLite >=3.25).
524
611
  return `ALTER TABLE ${table} RENAME COLUMN ${from} TO ${to};`;
525
- }
612
+ }
613
+
614
+ function manualMigrationRequiredStatement(message: string): string {
615
+ return `SELECT mandu_manual_migration_required(${sqlStringLiteral(message)});`;
616
+ }
617
+
618
+ function sqlStringLiteral(value: string): string {
619
+ return `'${value.replace(/'/g, "''")}'`;
620
+ }
526
621
 
527
622
  // =====================================================================
528
623
  // Validation helpers.
@@ -363,21 +363,17 @@ ${result.migrationSql}`;
363
363
  // ============================================
364
364
 
365
365
  /**
366
- * Wrap the sequence of `Change` → SQL emission with a transaction
367
- * header/footer. SQLite uses `BEGIN` / `COMMIT` (plain) because its
368
- * migration runner invokes each file via `db.transaction()` anyway
369
- * but the explicit BEGIN/COMMIT is harmless inside an already-open tx
370
- * and makes the file runnable standalone via `sqlite3 foo.db < file.sql`.
371
- */
372
- function composeMigrationSql(changes: readonly Change[], provider: SqlProvider): string {
373
- const body = emitChanges(changes, provider);
374
- if (body.length === 0) return "";
375
- return `BEGIN;
376
-
377
- ${body}
378
-
379
- COMMIT;`;
380
- }
366
+ * Compose the sequence of `Change` → SQL emission.
367
+ *
368
+ * Do not wrap with `BEGIN` / `COMMIT`: the migration runner already applies
369
+ * each file inside a transaction, and SQLite rejects nested BEGIN with
370
+ * "cannot start a transaction within a transaction".
371
+ */
372
+ function composeMigrationSql(changes: readonly Change[], provider: SqlProvider): string {
373
+ const body = emitChanges(changes, provider);
374
+ if (body.length === 0) return "";
375
+ return body;
376
+ }
381
377
 
382
378
  // ============================================
383
379
  // Internals — filesystem I/O
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ findClientComponentImports,
4
+ findRouteLevelClientComponentImport,
5
+ } from "./client-entry";
6
+
7
+ describe("findClientComponentImports", () => {
8
+ it("detects named .client imports for diagnostics", () => {
9
+ const imports = findClientComponentImports(`
10
+ import { LoginForm, SubmitButton as Button } from "@/client/widgets/login-form/LoginForm.client";
11
+ import Header from "./Header.client.tsx";
12
+ `);
13
+
14
+ expect(imports).toEqual([
15
+ {
16
+ module: "@/client/widgets/login-form/LoginForm.client",
17
+ kind: "named",
18
+ names: ["LoginForm", "SubmitButton"],
19
+ },
20
+ {
21
+ module: "./Header.client.tsx",
22
+ kind: "default",
23
+ names: ["Header"],
24
+ },
25
+ ]);
26
+ });
27
+
28
+ it("detects a default-imported client component when the page returns only that component", () => {
29
+ const routeClient = findRouteLevelClientComponentImport(`
30
+ import LoginPage from "@/client/pages/login/LoginPage.client";
31
+
32
+ export const metadata = { title: "Login" };
33
+
34
+ export default function Page() {
35
+ return <LoginPage />;
36
+ }
37
+ `);
38
+
39
+ expect(routeClient).toEqual({
40
+ module: "@/client/pages/login/LoginPage.client",
41
+ localName: "LoginPage",
42
+ });
43
+ });
44
+
45
+ it("does not promote embedded client imports inside a larger server page", () => {
46
+ const routeClient = findRouteLevelClientComponentImport(`
47
+ import HomeApp from "@/client/pages/home/HomeApp.client";
48
+
49
+ export default function HomePage() {
50
+ return <>
51
+ <meta name="description" content="home" />
52
+ <HomeApp />
53
+ </>;
54
+ }
55
+ `);
56
+
57
+ expect(routeClient).toBeNull();
58
+ });
59
+
60
+ it("does not promote client imports when the page wrapper passes props", () => {
61
+ const routeClient = findRouteLevelClientComponentImport(`
62
+ import PledgePage from "@/client/pages/pledges/PledgePage.client";
63
+
64
+ export default function Page({ params }) {
65
+ return <PledgePage id={params.id} />;
66
+ }
67
+ `);
68
+
69
+ expect(routeClient).toBeNull();
70
+ });
71
+ });
@@ -2,6 +2,17 @@ import { readFile } from "fs/promises";
2
2
  import path from "path";
3
3
  import type { RouteSpec } from "../spec/schema";
4
4
 
5
+ export interface ClientComponentImport {
6
+ module: string;
7
+ kind: "default" | "named" | "namespace" | "side-effect" | "mixed";
8
+ names: string[];
9
+ }
10
+
11
+ export interface RouteLevelClientComponentImport {
12
+ module: string;
13
+ localName: string;
14
+ }
15
+
5
16
  export function normalizeRouteModulePath(value: string | undefined): string {
6
17
  return (value ?? "").replace(/\\/g, "/").replace(/^\.\//, "");
7
18
  }
@@ -32,6 +43,95 @@ async function readRouteModule(rootDir: string, modulePath: string): Promise<str
32
43
  }
33
44
  }
34
45
 
46
+ export function findClientComponentImports(source: string): ClientComponentImport[] {
47
+ const imports: ClientComponentImport[] = [];
48
+ const importFromPattern = /import\s+([\s\S]*?)\s+from\s+["']([^"']*\.client(?:\.[tj]sx?)?)["']/g;
49
+ const sideEffectPattern = /import\s+["']([^"']*\.client(?:\.[tj]sx?)?)["']/g;
50
+
51
+ for (const match of source.matchAll(importFromPattern)) {
52
+ const clause = (match[1] ?? "").trim();
53
+ const module = match[2] ?? "";
54
+ const names: string[] = [];
55
+ let hasDefault = false;
56
+ let hasNamed = false;
57
+ let hasNamespace = false;
58
+
59
+ const namedMatch = clause.match(/\{([^}]*)\}/);
60
+ if (namedMatch) {
61
+ hasNamed = true;
62
+ for (const rawName of namedMatch[1].split(",")) {
63
+ const name = rawName.trim();
64
+ if (!name) continue;
65
+ names.push(name.split(/\s+as\s+/i)[0].trim());
66
+ }
67
+ }
68
+
69
+ if (/\*\s+as\s+/.test(clause)) {
70
+ hasNamespace = true;
71
+ const namespaceMatch = clause.match(/\*\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*)/);
72
+ if (namespaceMatch?.[1]) names.push(namespaceMatch[1]);
73
+ }
74
+
75
+ const beforeNamed = clause.split("{")[0]?.replace(/,\s*$/, "").trim() ?? "";
76
+ if (beforeNamed && !beforeNamed.startsWith("*")) {
77
+ hasDefault = true;
78
+ names.push(beforeNamed.split(",")[0].trim());
79
+ }
80
+
81
+ const kind =
82
+ (hasDefault && (hasNamed || hasNamespace))
83
+ ? "mixed"
84
+ : hasNamed
85
+ ? "named"
86
+ : hasNamespace
87
+ ? "namespace"
88
+ : "default";
89
+
90
+ imports.push({ module, kind, names });
91
+ }
92
+
93
+ for (const match of source.matchAll(sideEffectPattern)) {
94
+ const module = match[1] ?? "";
95
+ if (imports.some((entry) => entry.module === module)) continue;
96
+ imports.push({ module, kind: "side-effect", names: [] });
97
+ }
98
+
99
+ return imports;
100
+ }
101
+
102
+ export function findRouteLevelClientComponentImport(source: string): RouteLevelClientComponentImport | null {
103
+ const defaultImports = findClientComponentImports(source).filter((entry) => {
104
+ const localName = entry.names[0] ?? "";
105
+ return entry.kind === "default" && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(localName);
106
+ });
107
+
108
+ if (defaultImports.length !== 1) return null;
109
+
110
+ const entry = defaultImports[0];
111
+ const localName = entry.names[0];
112
+ if (!entry.module || !localName) return null;
113
+ if (!defaultExportReturnsOnlyClientComponent(source, localName)) return null;
114
+
115
+ return { module: entry.module, localName };
116
+ }
117
+
118
+ export async function resolveClientImportModulePath(
119
+ rootDir: string,
120
+ importerModule: string,
121
+ specifier: string,
122
+ ): Promise<string | null> {
123
+ const base = resolveImportBasePath(rootDir, importerModule, specifier);
124
+ if (!base) return null;
125
+
126
+ for (const candidate of expandClientModuleCandidates(base)) {
127
+ if (await Bun.file(candidate).exists()) {
128
+ return path.relative(rootDir, candidate).replace(/\\/g, "/");
129
+ }
130
+ }
131
+
132
+ return null;
133
+ }
134
+
35
135
  export async function shouldPreserveExistingClientModule(
36
136
  route: RouteSpec,
37
137
  clientModule: string,
@@ -46,6 +146,147 @@ export async function shouldPreserveExistingClientModule(
46
146
  return true;
47
147
  }
48
148
 
149
+ function resolveImportBasePath(rootDir: string, importerModule: string, specifier: string): string | null {
150
+ const normalized = specifier.replace(/\\/g, "/");
151
+ if (normalized.startsWith("@/") || normalized.startsWith("~/")) {
152
+ return path.resolve(rootDir, "src", normalized.slice(2));
153
+ }
154
+ if (normalized.startsWith("./") || normalized.startsWith("../")) {
155
+ return path.resolve(rootDir, path.dirname(importerModule), normalized);
156
+ }
157
+ return null;
158
+ }
159
+
160
+ function expandClientModuleCandidates(basePath: string): string[] {
161
+ if (/\.[cm]?[jt]sx?$/.test(basePath)) return [basePath];
162
+ return [
163
+ `${basePath}.tsx`,
164
+ `${basePath}.ts`,
165
+ `${basePath}.jsx`,
166
+ `${basePath}.js`,
167
+ ];
168
+ }
169
+
170
+ function defaultExportReturnsOnlyClientComponent(source: string, localName: string): boolean {
171
+ const functionBody = extractDefaultExportFunctionBody(source);
172
+ if (functionBody !== null) {
173
+ const returned = extractOnlyReturnExpression(functionBody);
174
+ return returned !== null && isSelfClosingJsxElement(returned, localName);
175
+ }
176
+
177
+ const arrowExpression = extractDefaultExportArrowExpression(source);
178
+ return arrowExpression !== null && isSelfClosingJsxElement(arrowExpression, localName);
179
+ }
180
+
181
+ function extractDefaultExportFunctionBody(source: string): string | null {
182
+ const match = /export\s+default\s+(?:async\s+)?function(?:\s+[A-Za-z_$][A-Za-z0-9_$]*)?\s*\([^)]*\)\s*(?::\s*[^{=]+)?\{/m.exec(source);
183
+ if (!match) return null;
184
+
185
+ const openBrace = match.index + match[0].lastIndexOf("{");
186
+ const closeBrace = findMatchingBrace(source, openBrace);
187
+ if (closeBrace === -1) return null;
188
+ return source.slice(openBrace + 1, closeBrace);
189
+ }
190
+
191
+ function extractDefaultExportArrowExpression(source: string): string | null {
192
+ const match = /export\s+default\s+(?:async\s+)?(?:\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)\s*=>\s*/m.exec(source);
193
+ if (!match) return null;
194
+
195
+ const start = match.index + match[0].length;
196
+ const rest = source.slice(start).trim();
197
+ if (rest.startsWith("{")) return null;
198
+
199
+ const semicolon = rest.indexOf(";");
200
+ return semicolon === -1 ? rest : rest.slice(0, semicolon);
201
+ }
202
+
203
+ function extractOnlyReturnExpression(body: string): string | null {
204
+ const match = /^\s*return\s+([\s\S]*?)\s*;?\s*$/.exec(body);
205
+ return match?.[1]?.trim() ?? null;
206
+ }
207
+
208
+ function isSelfClosingJsxElement(expression: string, localName: string): boolean {
209
+ const expr = stripWrappingParentheses(expression.trim());
210
+ const escaped = escapeRegExp(localName);
211
+ return new RegExp(`^<${escaped}\\s*/>$`).test(expr);
212
+ }
213
+
214
+ function stripWrappingParentheses(value: string): string {
215
+ let current = value.trim();
216
+ while (current.startsWith("(") && current.endsWith(")")) {
217
+ const close = findMatchingParen(current, 0);
218
+ if (close !== current.length - 1) break;
219
+ current = current.slice(1, -1).trim();
220
+ }
221
+ return current;
222
+ }
223
+
224
+ function findMatchingBrace(source: string, openIndex: number): number {
225
+ return findMatchingDelimiter(source, openIndex, "{", "}");
226
+ }
227
+
228
+ function findMatchingParen(source: string, openIndex: number): number {
229
+ return findMatchingDelimiter(source, openIndex, "(", ")");
230
+ }
231
+
232
+ function findMatchingDelimiter(source: string, openIndex: number, open: string, close: string): number {
233
+ let depth = 0;
234
+ let quote: '"' | "'" | "`" | null = null;
235
+ let lineComment = false;
236
+ let blockComment = false;
237
+
238
+ for (let i = openIndex; i < source.length; i++) {
239
+ const char = source[i];
240
+ const next = source[i + 1];
241
+ const prev = source[i - 1];
242
+
243
+ if (lineComment) {
244
+ if (char === "\n" || char === "\r") lineComment = false;
245
+ continue;
246
+ }
247
+
248
+ if (blockComment) {
249
+ if (char === "*" && next === "/") {
250
+ blockComment = false;
251
+ i++;
252
+ }
253
+ continue;
254
+ }
255
+
256
+ if (quote) {
257
+ if (char === quote && prev !== "\\") quote = null;
258
+ continue;
259
+ }
260
+
261
+ if (char === "/" && next === "/") {
262
+ lineComment = true;
263
+ i++;
264
+ continue;
265
+ }
266
+ if (char === "/" && next === "*") {
267
+ blockComment = true;
268
+ i++;
269
+ continue;
270
+ }
271
+ if (char === '"' || char === "'" || char === "`") {
272
+ quote = char;
273
+ continue;
274
+ }
275
+
276
+ if (char === open) depth++;
277
+ if (char === close) {
278
+ depth--;
279
+ if (depth === 0) return i;
280
+ }
281
+ }
282
+
283
+ return -1;
284
+ }
285
+
286
+ function escapeRegExp(value: string): string {
287
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
288
+ }
289
+
49
290
  export async function validateClientModuleForBrowserBundle(
50
291
  route: RouteSpec,
51
292
  rootDir: string,
@@ -69,3 +310,48 @@ export async function validateClientModuleForBrowserBundle(
69
310
 
70
311
  return null;
71
312
  }
313
+
314
+ export async function describeMissingHydrationClientModule(
315
+ route: RouteSpec,
316
+ rootDir: string,
317
+ options: { allowPartialOnly?: boolean } = {},
318
+ ): Promise<string | null> {
319
+ const hydration = route.hydration?.strategy ?? "island";
320
+ const base =
321
+ `[${route.id}] Route has hydration strategy "${hydration}" but no clientModule could be resolved. ` +
322
+ `Mandu cannot emit a working data-mandu-src for this route.`;
323
+
324
+ const componentModule = route.kind === "page" ? route.componentModule : undefined;
325
+ if (!componentModule) {
326
+ return options.allowPartialOnly && hydration === "island" ? null : base;
327
+ }
328
+
329
+ const source = await readRouteModule(rootDir, componentModule);
330
+ if (source === null) {
331
+ return options.allowPartialOnly && hydration === "island" ? null : base;
332
+ }
333
+
334
+ const clientImports = findClientComponentImports(source);
335
+ if (clientImports.length === 0) {
336
+ if (options.allowPartialOnly && hydration === "island") return null;
337
+ return (
338
+ `${base}\n` +
339
+ ` Fix: add a route-level client module (for example app/*.island.tsx or spec/slots/${route.id}.client.tsx) ` +
340
+ `or set hydration.strategy to "none".`
341
+ );
342
+ }
343
+
344
+ const importList = clientImports
345
+ .map((entry) => {
346
+ const suffix = entry.names.length > 0 ? ` (${entry.kind}: ${entry.names.join(", ")})` : ` (${entry.kind})`;
347
+ return ` - ${entry.module}${suffix}`;
348
+ })
349
+ .join("\n");
350
+
351
+ return (
352
+ `${base}\n` +
353
+ ` The page imports client-looking modules, but inline .client.tsx imports are not route bundles:\n` +
354
+ `${importList}\n` +
355
+ ` Fix: use partial({ component }).Render for embedded client regions, or expose a route-level client module.`
356
+ );
357
+ }