@mandujs/core 0.54.16 → 0.54.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/package.json +3 -1
  2. package/src/agent/__tests__/context.test.ts +54 -16
  3. package/src/agent/context.ts +17 -0
  4. package/src/agent/types.ts +32 -12
  5. package/src/bundler/__snapshots__/build.test.ts.snap +5 -0
  6. package/src/bundler/__tests__/build-runner.ts +130 -17
  7. package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -0
  8. package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -33
  9. package/src/bundler/build.test.ts +440 -8
  10. package/src/bundler/build.ts +455 -132
  11. package/src/bundler/client-boundary-transform.ts +977 -0
  12. package/src/bundler/dev.ts +39 -112
  13. package/src/bundler/fast-refresh-preamble.ts +47 -0
  14. package/src/bundler/index.ts +3 -2
  15. package/src/bundler/manifest-schema.ts +10 -0
  16. package/src/bundler/types.ts +20 -2
  17. package/src/diagnose/__tests__/checks.test.ts +117 -17
  18. package/src/diagnose/checks.ts +184 -3
  19. package/src/diagnose/run.ts +10 -8
  20. package/src/generator/templates.test.ts +48 -5
  21. package/src/generator/templates.ts +10 -1
  22. package/src/internal/client-boundary.ts +266 -0
  23. package/src/internal/index.ts +2 -1
  24. package/src/router/client-entry.test.ts +43 -6
  25. package/src/router/client-entry.ts +33 -12
  26. package/src/router/fs-routes.test.ts +388 -1
  27. package/src/router/fs-routes.ts +16 -3
  28. package/src/router/fs-scanner.ts +166 -18
  29. package/src/router/fs-types.ts +4 -1
  30. package/src/runtime/__tests__/inline-client-hydration.test.ts +134 -0
  31. package/src/runtime/__tests__/page-render-response.test.ts +212 -0
  32. package/src/runtime/handlers.ts +50 -26
  33. package/src/runtime/page-render-response.ts +43 -3
  34. package/src/runtime/server.ts +42 -5
  35. package/src/runtime/ssr.ts +16 -5
  36. package/src/runtime/streaming-ssr.ts +119 -76
  37. package/src/spec/schema.ts +31 -5
@@ -21,12 +21,13 @@ import {
21
21
  scanFileImports,
22
22
  DEFAULT_MAX_CLOSURE_DEPTH,
23
23
  } from "./reverse-import-graph";
24
- import path from "path";
25
- import fs from "fs";
26
- import { LRUCache } from "../utils/lru-cache";
27
- import { registerCacheSize, unregisterCacheSize } from "../observability/metrics";
28
-
29
- /**
24
+ import path from "path";
25
+ import fs from "fs";
26
+ import { LRUCache } from "../utils/lru-cache";
27
+ import { registerCacheSize, unregisterCacheSize } from "../observability/metrics";
28
+ export { generateFastRefreshPreamble } from "./fast-refresh-preamble";
29
+
30
+ /**
30
31
  * #184: 공통 디렉토리 변경 시 사용하는 sentinel.
31
32
  * `onSSRChange`에 특정 파일 경로 대신 이 상수를 전달하면 "전체 SSR 레지스트리 invalidate" 의미.
32
33
  */
@@ -135,12 +136,14 @@ export interface RebuildResult {
135
136
  error?: string;
136
137
  }
137
138
 
138
- export interface DevBundler {
139
- /** 초기 빌드 결과 */
140
- initialBuild: BundleResult;
141
- /** 파일 감시 중지 */
142
- close: () => void;
143
- }
139
+ export interface DevBundler {
140
+ /** 초기 빌드 결과 */
141
+ initialBuild: BundleResult;
142
+ /** Reverse import graph initial seed completion for deterministic tests. */
143
+ reverseGraphReady: Promise<void>;
144
+ /** 파일 감시 중지 */
145
+ close: () => void;
146
+ }
144
147
 
145
148
  /**
146
149
  * #180: 파일 경로 비교를 위한 정규화.
@@ -647,7 +650,8 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
647
650
  // surface typed-only stubs that have no source yet); misses simply
648
651
  // keep the legacy "silent drop" behavior so no project is made
649
652
  // worse by turning this on.
650
- const reverseGraph = new ReverseImportGraph();
653
+ const reverseGraph = new ReverseImportGraph();
654
+ let reverseGraphSeedPromise: Promise<void> | null = null;
651
655
 
652
656
  /**
653
657
  * Re-scan a single file's imports and update its row in the
@@ -1239,11 +1243,15 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
1239
1243
  * stay silent. Dispatch is idempotent — each root is touched at
1240
1244
  * most once per change via the `dispatched` set.
1241
1245
  */
1242
- const dispatchByTransitiveImporters = async (
1243
- changedFile: string,
1244
- ): Promise<number> => {
1245
- const absChanged = path.resolve(rootDir, changedFile);
1246
- // Refresh the changed file's OWN imports first. A leaf edit can
1246
+ const dispatchByTransitiveImporters = async (
1247
+ changedFile: string,
1248
+ ): Promise<number> => {
1249
+ const absChanged = path.resolve(rootDir, changedFile);
1250
+ if (reverseGraphSeedPromise) {
1251
+ await reverseGraphSeedPromise;
1252
+ }
1253
+
1254
+ // Refresh the changed file's OWN imports first. A leaf edit can
1247
1255
  // legitimately add or remove imports (e.g. switching a barrel
1248
1256
  // from `./en` to `./ko`); the reverse graph must track that so
1249
1257
  // the NEXT unknown-file change uses the correct set.
@@ -1655,16 +1663,17 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
1655
1663
  // and the first user edit can't race the seed (first event goes
1656
1664
  // through the 100 ms debounce, by which point the scan has finished
1657
1665
  // for any realistic project size).
1658
- seedReverseGraph().catch((err) => {
1659
- console.warn(
1660
- "[Mandu HMR] reverse import-graph seed skipped:",
1661
- err instanceof Error ? err.message : String(err),
1662
- );
1663
- });
1664
-
1665
- return {
1666
- initialBuild,
1667
- close: () => {
1666
+ reverseGraphSeedPromise = seedReverseGraph().catch((err) => {
1667
+ console.warn(
1668
+ "[Mandu HMR] reverse import-graph seed skipped:",
1669
+ err instanceof Error ? err.message : String(err),
1670
+ );
1671
+ });
1672
+
1673
+ return {
1674
+ initialBuild,
1675
+ reverseGraphReady: reverseGraphSeedPromise,
1676
+ close: () => {
1668
1677
  // B6: clear all per-file timers to release event-loop refs.
1669
1678
  // Phase 17 — `LRUCache.clear()` fires the registered `onEvict`
1670
1679
  // (`clearTimeout(timer)`) for every entry before dropping them,
@@ -2208,90 +2217,8 @@ export function createHMRServer(
2208
2217
  };
2209
2218
  }
2210
2219
 
2211
- /**
2212
- * Phase 7.1 B-3 — HTML preamble for React Fast Refresh.
2213
- *
2214
- * Emitted by the SSR renderer in dev mode, **before** any island JS
2215
- * evaluates. Two concerns, one <script>:
2216
- *
2217
- * 1. Install inert stubs for `$RefreshReg$` / `$RefreshSig$` on
2218
- * `window`. Bun's `reactFastRefresh: true` transform inserts calls
2219
- * to these at the top of every transformed module; if they are
2220
- * undefined when the module body runs, we get a runtime error and
2221
- * the island never hydrates. Vite's preamble does the same inline
2222
- * stub install for the same reason.
2223
- *
2224
- * 2. Fire a dynamic `import()` of the bundled glue (`_fast-refresh-
2225
- * runtime.js`), which in turn `await`s the real `react-refresh/
2226
- * runtime`, installs `window.__MANDU_HMR__`, and upgrades the
2227
- * stubs to live wrappers that forward to the refresh runtime. The
2228
- * race between "module evaluates and calls `$RefreshReg$`" and
2229
- * "glue has upgraded the stubs" is benign — registrations that
2230
- * land on the stub are simply no-ops, which at worst means the
2231
- * very first mount isn't tracked. Subsequent hot swaps land on
2232
- * the live wrappers and work normally.
2233
- *
2234
- * The emitted script is **inline** (no `type="module"`, no external
2235
- * src). This is deliberate: the stubs must exist before *any* module
2236
- * script runs, and inline execution blocks the parser. The dynamic
2237
- * import inside the inline script is non-blocking so we don't stall
2238
- * First Contentful Paint.
2239
- *
2240
- * CSP note: the inline <script> uses no `eval` or `new Function`; it
2241
- * only calls `Object.assign`, defines functions, and initiates an
2242
- * `import()`. All of these are permitted under `script-src 'self'
2243
- * 'unsafe-inline'` which is Mandu's default dev CSP (production CSP
2244
- * forbids `unsafe-inline`, but this preamble is dev-only).
2245
- *
2246
- * `glueUrl` and `runtimeUrl` come from the build manifest's
2247
- * `shared.fastRefresh` block (populated only in dev). Both must be
2248
- * absolute URLs served from the same origin as the HTML, which our
2249
- * bundler always guarantees (`/.mandu/client/...`).
2250
- */
2251
- export function generateFastRefreshPreamble(
2252
- glueUrl: string,
2253
- runtimeUrl: string,
2254
- ): string {
2255
- // Both URLs must be non-empty. If either is missing (e.g. vendor
2256
- // shim build failed), the caller (ssr.ts) should skip this preamble
2257
- // entirely — defensive guard here keeps the output valid regardless.
2258
- if (!glueUrl || !runtimeUrl) {
2259
- return `<script>/* Mandu Fast Refresh: missing runtime assets, preamble skipped */</script>`;
2260
- }
2261
- // JSON.stringify escapes the URLs safely for inline `<script>`:
2262
- // - quotes produce a valid JS string literal
2263
- // - forward-slashes / backslashes are handled
2264
- // We also `split('</')` to avoid a stray `</script>` sequence in the
2265
- // URL bytes breaking the enclosing tag. This is the same defense
2266
- // Vite uses in its own preamble emitter.
2267
- const glueLit = JSON.stringify(glueUrl).split("</").join('<"+"/');
2268
- const runtimeLit = JSON.stringify(runtimeUrl).split("</").join('<"+"/');
2269
- return `<script>
2270
- // Phase 7.1 B-3 React Fast Refresh preamble (Mandu dev-only)
2271
- (function () {
2272
- if (typeof window === "undefined") return;
2273
- // Install inert stubs so transformed modules that run BEFORE the
2274
- // async runtime upgrade don't hit ReferenceError on $RefreshReg$.
2275
- if (!window.$RefreshReg$) window.$RefreshReg$ = function () {};
2276
- if (!window.$RefreshSig$) window.$RefreshSig$ = function () { return function (t) { return t; }; };
2277
- // Async-load the glue; failures are reported but never throw out of
2278
- // the preamble — a missing runtime degrades to full-reload HMR.
2279
- import(${glueLit})
2280
- .then(function (mod) {
2281
- var runtimeImport = function () { return import(${runtimeLit}); };
2282
- if (mod && typeof mod.installGlobal === "function") {
2283
- return mod.installGlobal({ runtimeImport: runtimeImport });
2284
- }
2285
- })
2286
- .catch(function (err) {
2287
- console.error("[Mandu Fast Refresh] preamble failed:", err);
2288
- });
2289
- })();
2290
- </script>`;
2291
- }
2292
-
2293
- /**
2294
- * HMR 클라이언트 스크립트 생성
2220
+ /**
2221
+ * HMR 클라이언트 스크립트 생성
2295
2222
  * 브라우저에서 실행되어 HMR 서버와 연결.
2296
2223
  *
2297
2224
  * Phase 7.0 R1 Agent C additions:
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Small runtime-safe Fast Refresh preamble helper.
3
+ *
4
+ * Keep this separate from `bundler/dev.ts`: SSR runtimes import the helper
5
+ * while edge bundles must not pull the dev bundler, build graph, or TypeScript
6
+ * compiler into production worker output.
7
+ */
8
+ export function generateFastRefreshPreamble(
9
+ glueUrl: string,
10
+ runtimeUrl: string,
11
+ ): string {
12
+ // Both URLs must be non-empty. If either is missing (e.g. vendor
13
+ // shim build failed), the caller should skip this preamble entirely.
14
+ if (!glueUrl || !runtimeUrl) {
15
+ return `<script>/* Mandu Fast Refresh: missing runtime assets, preamble skipped */</script>`;
16
+ }
17
+ // JSON.stringify escapes the URLs safely for inline `<script>`:
18
+ // - quotes produce a valid JS string literal
19
+ // - forward-slashes / backslashes are handled
20
+ // We also `split('</')` to avoid a stray `</script>` sequence in the
21
+ // URL bytes breaking the enclosing tag. This is the same defense
22
+ // Vite uses in its own preamble emitter.
23
+ const glueLit = JSON.stringify(glueUrl).split("</").join('<"+"/');
24
+ const runtimeLit = JSON.stringify(runtimeUrl).split("</").join('<"+"/');
25
+ return `<script>
26
+ // Phase 7.1 B-3 React Fast Refresh preamble (Mandu dev-only)
27
+ (function () {
28
+ if (typeof window === "undefined") return;
29
+ // Install inert stubs so transformed modules that run BEFORE the
30
+ // async runtime upgrade don't hit ReferenceError on $RefreshReg$.
31
+ if (!window.$RefreshReg$) window.$RefreshReg$ = function () {};
32
+ if (!window.$RefreshSig$) window.$RefreshSig$ = function () { return function (t) { return t; }; };
33
+ // Async-load the glue; failures are reported but never throw out of
34
+ // the preamble — a missing runtime degrades to full-reload HMR.
35
+ import(${glueLit})
36
+ .then(function (mod) {
37
+ var runtimeImport = function () { return import(${runtimeLit}); };
38
+ if (mod && typeof mod.installGlobal === "function") {
39
+ return mod.installGlobal({ runtimeImport: runtimeImport });
40
+ }
41
+ })
42
+ .catch(function (err) {
43
+ console.error("[Mandu Fast Refresh] preamble failed:", err);
44
+ });
45
+ })();
46
+ </script>`;
47
+ }
@@ -4,7 +4,8 @@
4
4
  */
5
5
 
6
6
  export * from "./types";
7
- export * from "./build";
8
- export * from "./dev";
7
+ export * from "./build";
8
+ export * from "./client-boundary-transform";
9
+ export * from "./dev";
9
10
  export * from "./css";
10
11
  export * from "./plugins";
@@ -173,6 +173,15 @@ const PartialEntrySchema = z.object({
173
173
  priority: PrioritySchema,
174
174
  });
175
175
 
176
+ const BoundaryEntrySchema = z.object({
177
+ route: z.string().min(1),
178
+ js: safeManduUrl("boundaries[].js"),
179
+ module: z.string().min(1),
180
+ exportName: z.string().min(1),
181
+ priority: PrioritySchema,
182
+ hydrate: z.string().min(1),
183
+ });
184
+
176
185
  const FastRefreshSchema = z.object({
177
186
  runtime: safeManduUrl("shared.fastRefresh.runtime"),
178
187
  glue: safeManduUrl("shared.fastRefresh.glue"),
@@ -229,6 +238,7 @@ export const BundleManifestSchema = z
229
238
  bundles: z.record(z.string(), BundleEntrySchema),
230
239
  islands: z.record(z.string(), IslandEntrySchema).optional(),
231
240
  partials: z.record(z.string(), PartialEntrySchema).optional(),
241
+ boundaries: z.record(z.string(), BoundaryEntrySchema).optional(),
232
242
  shared: SharedSchema,
233
243
  importMap: ImportMapSchema.optional(),
234
244
  })
@@ -77,8 +77,26 @@ export interface BundleManifest {
77
77
  priority: "immediate" | "visible" | "idle" | "interaction";
78
78
  }
79
79
  >;
80
- /** 공유 청크 */
81
- shared: {
80
+ /** Compiler-owned client boundary bundles keyed by boundary id. */
81
+ boundaries?: Record<
82
+ string,
83
+ {
84
+ /** Owning route id */
85
+ route: string;
86
+ /** JavaScript bundle path */
87
+ js: string;
88
+ /** Source client module */
89
+ module: string;
90
+ /** Client export name */
91
+ exportName: string;
92
+ /** Hydration priority */
93
+ priority: "immediate" | "visible" | "idle" | "interaction";
94
+ /** Hydration mode emitted into data-hydrate */
95
+ hydrate: string;
96
+ }
97
+ >;
98
+ /** 공유 청크 */
99
+ shared: {
82
100
  /** Hydration 런타임 */
83
101
  runtime: string;
84
102
  /** React 번들 경로 */
@@ -16,9 +16,10 @@ import {
16
16
  checkPrerenderPollution,
17
17
  checkCloneElementWarnings,
18
18
  checkDevArtifactsInProd,
19
- checkPackageExportGaps,
20
- checkNestedInternalCore,
21
- } from "../checks";
19
+ checkPackageExportGaps,
20
+ checkNestedInternalCore,
21
+ checkClientBoundaryManifests,
22
+ } from "../checks";
22
23
  import { runExtendedDiagnose, buildReport } from "../run";
23
24
 
24
25
  async function mkTmpRoot(): Promise<string> {
@@ -324,7 +325,7 @@ describe("checkPackageExportGaps", () => {
324
325
  // nested_internal_core (#261)
325
326
  // ──────────────────────────────────────────────────────────────────
326
327
 
327
- describe("checkNestedInternalCore", () => {
328
+ describe("checkNestedInternalCore", () => {
328
329
  let rootDir: string;
329
330
  beforeEach(async () => { rootDir = await mkTmpRoot(); });
330
331
  afterEach(async () => { await fs.rm(rootDir, { recursive: true, force: true }); });
@@ -387,21 +388,119 @@ describe("checkNestedInternalCore", () => {
387
388
  expect(result.ok).toBe(false);
388
389
  expect(result.details?.mismatchCount).toBe(2);
389
390
  });
390
- });
391
-
392
- // ──────────────────────────────────────────────────────────────────
393
- // aggregator
394
- // ──────────────────────────────────────────────────────────────────
391
+ });
392
+
393
+ // ──────────────────────────────────────────────────────────────────
394
+ // client_boundary_manifests
395
+ // ──────────────────────────────────────────────────────────────────
396
+
397
+ describe("checkClientBoundaryManifests", () => {
398
+ let rootDir: string;
399
+ beforeEach(async () => { rootDir = await mkTmpRoot(); });
400
+ afterEach(async () => { await fs.rm(rootDir, { recursive: true, force: true }); });
401
+
402
+ it("skips gracefully when routes manifest is missing", async () => {
403
+ const result = await checkClientBoundaryManifests(rootDir);
404
+ expect(result.ok).toBe(true);
405
+ expect(result.details?.skipped).toBe(true);
406
+ });
407
+
408
+ it("passes when the routes manifest has no compiler-owned boundaries", async () => {
409
+ await writeFile(rootDir, ".mandu/routes.manifest.json", JSON.stringify({
410
+ routes: [{ id: "home", kind: "page" }],
411
+ }));
412
+ const result = await checkClientBoundaryManifests(rootDir);
413
+ expect(result.ok).toBe(true);
414
+ expect(result.details?.boundaryCount).toBe(0);
415
+ });
416
+
417
+ it("flags duplicate client boundary ids in the routes manifest", async () => {
418
+ await writeFile(rootDir, ".mandu/routes.manifest.json", JSON.stringify({
419
+ routes: [
420
+ {
421
+ id: "a",
422
+ boundaries: [{ id: "dup--0", routeId: "a", module: "src/client/A.client.tsx", exportName: "A" }],
423
+ },
424
+ {
425
+ id: "b",
426
+ boundaries: [{ id: "dup--0", routeId: "b", module: "src/client/B.client.tsx", exportName: "B" }],
427
+ },
428
+ ],
429
+ }));
430
+ const result = await checkClientBoundaryManifests(rootDir);
431
+ expect(result.ok).toBe(false);
432
+ expect(result.severity).toBe("error");
433
+ expect(result.message).toMatch(/duplicate/);
434
+ });
435
+
436
+ it("flags missing boundary bundle manifest entries", async () => {
437
+ await writeFile(rootDir, ".mandu/routes.manifest.json", JSON.stringify({
438
+ routes: [
439
+ {
440
+ id: "home",
441
+ boundaries: [{ id: "home--0", routeId: "home", module: "src/client/Home.client.tsx", exportName: "Home" }],
442
+ },
443
+ ],
444
+ }));
445
+ await writeFile(rootDir, ".mandu/manifest.json", JSON.stringify({
446
+ version: 1,
447
+ buildTime: "x",
448
+ env: "production",
449
+ bundles: {},
450
+ boundaries: {},
451
+ shared: { runtime: "/.mandu/client/_runtime.js", vendor: "/.mandu/client/_react.js" },
452
+ }));
453
+ const result = await checkClientBoundaryManifests(rootDir);
454
+ expect(result.ok).toBe(false);
455
+ expect(result.severity).toBe("error");
456
+ expect(result.message).toMatch(/incomplete/);
457
+ expect(result.details?.missingEntries).toEqual(["home--0"]);
458
+ });
459
+
460
+ it("passes when route and bundle boundary manifests line up", async () => {
461
+ await writeFile(rootDir, ".mandu/routes.manifest.json", JSON.stringify({
462
+ routes: [
463
+ {
464
+ id: "home",
465
+ boundaries: [{ id: "home--0", routeId: "home", module: "src/client/Home.client.tsx", exportName: "Home" }],
466
+ },
467
+ ],
468
+ }));
469
+ await writeFile(rootDir, ".mandu/client/home--0.boundary.js", "export default function Home() {}\n");
470
+ await writeFile(rootDir, ".mandu/manifest.json", JSON.stringify({
471
+ version: 1,
472
+ buildTime: "x",
473
+ env: "production",
474
+ bundles: {},
475
+ boundaries: {
476
+ "home--0": {
477
+ route: "home",
478
+ js: "/.mandu/client/home--0.boundary.js",
479
+ module: "src/client/Home.client.tsx",
480
+ exportName: "Home",
481
+ },
482
+ },
483
+ shared: { runtime: "/.mandu/client/_runtime.js", vendor: "/.mandu/client/_react.js" },
484
+ }));
485
+ const result = await checkClientBoundaryManifests(rootDir);
486
+ expect(result.ok).toBe(true);
487
+ expect(result.details?.boundaryCount).toBe(1);
488
+ });
489
+ });
490
+
491
+ // ──────────────────────────────────────────────────────────────────
492
+ // aggregator
493
+ // ──────────────────────────────────────────────────────────────────
395
494
 
396
495
  describe("runExtendedDiagnose", () => {
397
496
  let rootDir: string;
398
497
  beforeEach(async () => { rootDir = await mkTmpRoot(); });
399
498
  afterEach(async () => { await fs.rm(rootDir, { recursive: true, force: true }); });
400
499
 
401
- it("runs all 7 extended checks and returns a structured report", async () => {
402
- const report = await runExtendedDiagnose(rootDir);
403
- // #261 added `nested_internal_core` — total is now 7.
404
- expect(report.summary.total).toBe(7);
500
+ it("runs all 8 extended checks and returns a structured report", async () => {
501
+ const report = await runExtendedDiagnose(rootDir);
502
+ // F42/F45 added `client_boundary_manifests` — total is now 8.
503
+ expect(report.summary.total).toBe(8);
405
504
  // manifest is missing → at least one error
406
505
  expect(report.healthy).toBe(false);
407
506
  expect(report.errorCount).toBeGreaterThanOrEqual(1);
@@ -410,10 +509,11 @@ describe("runExtendedDiagnose", () => {
410
509
  expect(rules).toContain("prerender_pollution");
411
510
  expect(rules).toContain("cloneelement_warnings");
412
511
  expect(rules).toContain("dev_artifacts_in_prod");
413
- expect(rules).toContain("package_export_gaps");
414
- expect(rules).toContain("nested_internal_core");
415
- expect(rules).toContain("a11y_hints");
416
- });
512
+ expect(rules).toContain("package_export_gaps");
513
+ expect(rules).toContain("nested_internal_core");
514
+ expect(rules).toContain("client_boundary_manifests");
515
+ expect(rules).toContain("a11y_hints");
516
+ });
417
517
 
418
518
  it("returns healthy=true when all checks pass (production manifest, no gaps)", async () => {
419
519
  await writeFile(rootDir, ".mandu/manifest.json", JSON.stringify({
@@ -767,7 +767,7 @@ async function listMandujsSiblings(rootDir: string): Promise<string[]> {
767
767
  * version to the hoisted core. A mismatch is reported as `error`
768
768
  * (boot-breaking on the user's machine) with a copy-pastable fix.
769
769
  */
770
- export async function checkNestedInternalCore(rootDir: string): Promise<DiagnoseCheckResult> {
770
+ export async function checkNestedInternalCore(rootDir: string): Promise<DiagnoseCheckResult> {
771
771
  const hoistedPath = path.join(rootDir, "node_modules", "@mandujs", "core", "package.json");
772
772
  const hoistedVersion = await readPackageVersion(hoistedPath);
773
773
 
@@ -828,5 +828,186 @@ export async function checkNestedInternalCore(rootDir: string): Promise<Diagnose
828
828
  mismatchCount: mismatches.length,
829
829
  mismatches,
830
830
  },
831
- };
832
- }
831
+ };
832
+ }
833
+
834
+ // ────────────────────────────────────────────────────────────────────────
835
+ // 8. client_boundary_manifests (F42/F45)
836
+ // ────────────────────────────────────────────────────────────────────────
837
+
838
+ interface DiagnoseRouteBoundary {
839
+ id?: unknown;
840
+ routeId?: unknown;
841
+ module?: unknown;
842
+ exportName?: unknown;
843
+ source?: unknown;
844
+ }
845
+
846
+ interface DiagnoseRouteRecord {
847
+ id?: unknown;
848
+ boundaries?: unknown;
849
+ }
850
+
851
+ async function readJsonObject(filePath: string): Promise<Record<string, unknown> | null> {
852
+ try {
853
+ const raw = await fs.readFile(filePath, "utf-8");
854
+ const parsed = JSON.parse(raw) as unknown;
855
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
856
+ ? parsed as Record<string, unknown>
857
+ : null;
858
+ } catch {
859
+ return null;
860
+ }
861
+ }
862
+
863
+ function toRouteRecords(value: unknown): DiagnoseRouteRecord[] {
864
+ if (!Array.isArray(value)) return [];
865
+ return value.filter((entry): entry is DiagnoseRouteRecord =>
866
+ !!entry && typeof entry === "object" && !Array.isArray(entry)
867
+ );
868
+ }
869
+
870
+ function toRouteBoundaries(route: DiagnoseRouteRecord): DiagnoseRouteBoundary[] {
871
+ if (!Array.isArray(route.boundaries)) return [];
872
+ return route.boundaries.filter((entry): entry is DiagnoseRouteBoundary =>
873
+ !!entry && typeof entry === "object" && !Array.isArray(entry)
874
+ );
875
+ }
876
+
877
+ function bundleAssetExists(rootDir: string, jsPath: unknown): Promise<boolean> {
878
+ if (typeof jsPath !== "string" || jsPath.length === 0) return Promise.resolve(false);
879
+ const relativePath = jsPath.startsWith("/")
880
+ ? jsPath.slice(1)
881
+ : jsPath;
882
+ return fs.access(path.join(rootDir, relativePath)).then(
883
+ () => true,
884
+ () => false,
885
+ );
886
+ }
887
+
888
+ /**
889
+ * F42/F45 check: validate compiler-owned client boundary records.
890
+ *
891
+ * This does not rebuild the app. It inspects generated route and bundle
892
+ * manifests and reports:
893
+ * - duplicate boundary ids in `.mandu/routes.manifest.json`
894
+ * - malformed boundary records missing id/module/exportName
895
+ * - missing boundary bundle entries in `.mandu/manifest.json`
896
+ * - boundary bundle entries whose JS asset is absent on disk
897
+ */
898
+ export async function checkClientBoundaryManifests(rootDir: string): Promise<DiagnoseCheckResult> {
899
+ const routesManifestPath = path.join(rootDir, ".mandu", "routes.manifest.json");
900
+ const routesManifest = await readJsonObject(routesManifestPath);
901
+
902
+ if (!routesManifest) {
903
+ return {
904
+ ok: true,
905
+ rule: "client_boundary_manifests",
906
+ message: "Routes manifest is missing or unreadable — boundary manifest check skipped.",
907
+ details: { skipped: true, routesManifestPath: path.relative(rootDir, routesManifestPath) },
908
+ };
909
+ }
910
+
911
+ const boundaries: Array<{ routeId: string; id: string; module: string; exportName: string }> = [];
912
+ const malformed: Array<{ routeId: string; reason: string }> = [];
913
+ const seen = new Map<string, string>();
914
+ const duplicates: Array<{ id: string; firstRouteId: string; duplicateRouteId: string }> = [];
915
+
916
+ for (const route of toRouteRecords(routesManifest.routes)) {
917
+ const routeId = typeof route.id === "string" ? route.id : "(unknown-route)";
918
+ for (const boundary of toRouteBoundaries(route)) {
919
+ const id = typeof boundary.id === "string" ? boundary.id : "";
920
+ const module = typeof boundary.module === "string" ? boundary.module : "";
921
+ const exportName = typeof boundary.exportName === "string" ? boundary.exportName : "";
922
+ if (!id || !module || !exportName) {
923
+ malformed.push({ routeId, reason: "Boundary record must include id, module, and exportName." });
924
+ continue;
925
+ }
926
+
927
+ const firstRouteId = seen.get(id);
928
+ if (firstRouteId) {
929
+ duplicates.push({ id, firstRouteId, duplicateRouteId: routeId });
930
+ } else {
931
+ seen.set(id, routeId);
932
+ }
933
+ boundaries.push({ routeId, id, module, exportName });
934
+ }
935
+ }
936
+
937
+ if (malformed.length > 0 || duplicates.length > 0) {
938
+ return {
939
+ ok: false,
940
+ rule: "client_boundary_manifests",
941
+ severity: "error",
942
+ message: `Client boundary route manifest has ${malformed.length} malformed record(s) and ${duplicates.length} duplicate id(s).`,
943
+ suggestion: "Regenerate routes with `mandu generate` or rerun the build; boundary ids must be unique and include id/module/exportName.",
944
+ details: {
945
+ boundaryCount: boundaries.length,
946
+ malformed: malformed.slice(0, 10),
947
+ duplicates: duplicates.slice(0, 10),
948
+ },
949
+ };
950
+ }
951
+
952
+ if (boundaries.length === 0) {
953
+ return {
954
+ ok: true,
955
+ rule: "client_boundary_manifests",
956
+ message: "No compiler-owned client boundaries recorded in the routes manifest.",
957
+ details: { boundaryCount: 0 },
958
+ };
959
+ }
960
+
961
+ const bundleManifestPath = path.join(rootDir, ".mandu", "manifest.json");
962
+ const bundleManifest = await readJsonObject(bundleManifestPath);
963
+ if (!bundleManifest) {
964
+ return {
965
+ ok: false,
966
+ rule: "client_boundary_manifests",
967
+ severity: "warning",
968
+ message: `Routes manifest declares ${boundaries.length} client boundary record(s), but bundle manifest is missing.`,
969
+ suggestion: "Run `mandu build` before deploy so boundary bundles are emitted and can be checked.",
970
+ details: { boundaryCount: boundaries.length, bundleManifestPath: path.relative(rootDir, bundleManifestPath) },
971
+ };
972
+ }
973
+
974
+ const bundleBoundaries = bundleManifest.boundaries && typeof bundleManifest.boundaries === "object" && !Array.isArray(bundleManifest.boundaries)
975
+ ? bundleManifest.boundaries as Record<string, unknown>
976
+ : {};
977
+ const missingEntries: string[] = [];
978
+ const missingAssets: string[] = [];
979
+
980
+ for (const boundary of boundaries) {
981
+ const entry = bundleBoundaries[boundary.id];
982
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
983
+ missingEntries.push(boundary.id);
984
+ continue;
985
+ }
986
+ const jsPath = (entry as { js?: unknown }).js;
987
+ if (!(await bundleAssetExists(rootDir, jsPath))) {
988
+ missingAssets.push(boundary.id);
989
+ }
990
+ }
991
+
992
+ if (missingEntries.length > 0 || missingAssets.length > 0) {
993
+ return {
994
+ ok: false,
995
+ rule: "client_boundary_manifests",
996
+ severity: "error",
997
+ message: `Client boundary bundle manifest is incomplete: ${missingEntries.length} missing entr${missingEntries.length === 1 ? "y" : "ies"}, ${missingAssets.length} missing asset(s).`,
998
+ suggestion: "Run `mandu clean && mandu build`; if the issue persists, inspect `mandu.route.boundaries` with `includeBundle: true`.",
999
+ details: {
1000
+ boundaryCount: boundaries.length,
1001
+ missingEntries: missingEntries.slice(0, 10),
1002
+ missingAssets: missingAssets.slice(0, 10),
1003
+ },
1004
+ };
1005
+ }
1006
+
1007
+ return {
1008
+ ok: true,
1009
+ rule: "client_boundary_manifests",
1010
+ message: `Client boundary manifests are consistent for ${boundaries.length} boundary record(s).`,
1011
+ details: { boundaryCount: boundaries.length },
1012
+ };
1013
+ }