@mandujs/core 0.54.17 β†’ 0.54.19

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 (46) hide show
  1. package/package.json +3 -1
  2. package/src/agent/__tests__/context.test.ts +94 -25
  3. package/src/agent/context.ts +17 -0
  4. package/src/agent/types.ts +32 -12
  5. package/src/agent/verify.ts +55 -24
  6. package/src/bundler/__snapshots__/build.test.ts.snap +5 -0
  7. package/src/bundler/__tests__/build-runner.ts +130 -17
  8. package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -0
  9. package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -33
  10. package/src/bundler/build.test.ts +478 -9
  11. package/src/bundler/build.ts +424 -746
  12. package/src/bundler/client-boundary-transform.ts +977 -0
  13. package/src/bundler/dev.ts +39 -112
  14. package/src/bundler/fast-refresh-preamble.ts +47 -0
  15. package/src/bundler/index.ts +3 -2
  16. package/src/bundler/manifest-schema.ts +10 -0
  17. package/src/bundler/types.ts +20 -2
  18. package/src/client/__tests__/props-serialization.test.ts +37 -0
  19. package/src/client/hydrate.ts +2 -2
  20. package/src/client/index.ts +1 -1
  21. package/src/client/props-serialization.ts +233 -0
  22. package/src/client/runtime-entry.ts +567 -0
  23. package/src/client/runtime.ts +1 -1
  24. package/src/client/serialize.ts +50 -404
  25. package/src/diagnose/__tests__/checks.test.ts +132 -17
  26. package/src/diagnose/checks.ts +184 -3
  27. package/src/diagnose/run.ts +10 -8
  28. package/src/generator/templates.test.ts +48 -5
  29. package/src/generator/templates.ts +10 -1
  30. package/src/internal/client-boundary.ts +266 -0
  31. package/src/internal/index.ts +2 -1
  32. package/src/router/client-entry.test.ts +154 -29
  33. package/src/router/client-entry.ts +111 -313
  34. package/src/router/fs-routes.test.ts +443 -1
  35. package/src/router/fs-routes.ts +16 -3
  36. package/src/router/fs-scanner.ts +176 -57
  37. package/src/router/fs-types.ts +11 -2
  38. package/src/router/route-source-analyzer.ts +521 -0
  39. package/src/runtime/__tests__/inline-client-hydration.test.ts +104 -1
  40. package/src/runtime/__tests__/page-render-response.test.ts +218 -0
  41. package/src/runtime/handlers.ts +50 -26
  42. package/src/runtime/page-render-response.ts +24 -1
  43. package/src/runtime/server.ts +14 -0
  44. package/src/runtime/ssr.ts +16 -5
  45. package/src/runtime/streaming-ssr.ts +119 -76
  46. package/src/spec/schema.ts +31 -5
@@ -1,404 +1,50 @@
1
- /**
2
- * Mandu Props Serialization πŸ“¦
3
- * Fresh μŠ€νƒ€μΌ κ³ κΈ‰ 직렬화/역직렬화
4
- *
5
- * @see https://fresh.deno.dev/docs/concepts/islands
6
- *
7
- * 지원 νƒ€μž…:
8
- * - μ›μ‹œν˜•: null, boolean, number, string, bigint, undefined
9
- * - 특수 객체: Date, URL, RegExp, Map, Set
10
- * - μˆœν™˜ μ°Έμ‘°
11
- * - 쀑첩 객체/λ°°μ—΄
12
- */
13
-
14
- // ============================================
15
- // νƒ€μž… 마컀
16
- // ============================================
17
-
18
- const TYPE_MARKERS = {
19
- /** undefined */
20
- UNDEFINED: "\x00_",
21
- /** Date */
22
- DATE: "\x00D",
23
- /** URL */
24
- URL: "\x00U",
25
- /** RegExp */
26
- REGEXP: "\x00R",
27
- /** Map */
28
- MAP: "\x00M",
29
- /** Set */
30
- SET: "\x00S",
31
- /** μˆœν™˜ μ°Έμ‘° */
32
- REF: "\x00$",
33
- /** BigInt */
34
- BIGINT: "\x00B",
35
- /** Symbol (μ œν•œμ  지원) */
36
- SYMBOL: "\x00Y",
37
- /** Error */
38
- ERROR: "\x00E",
39
- } as const;
40
-
41
- // ============================================
42
- // 직렬화
43
- // ============================================
44
-
45
- /**
46
- * 직렬화 μ»¨ν…μŠ€νŠΈ (μˆœν™˜ μ°Έμ‘° 좔적)
47
- */
48
- interface SerializeContext {
49
- /** 이미 λ³Έ 객체 β†’ 인덱슀 */
50
- seen: Map<object, number>;
51
- /** μ°Έμ‘° ν…Œμ΄λΈ” */
52
- refs: object[];
53
- }
54
-
55
- /**
56
- * Props 직렬화
57
- *
58
- * @example
59
- * ```typescript
60
- * const props = {
61
- * date: new Date(),
62
- * url: new URL('https://example.com'),
63
- * items: new Set([1, 2, 3]),
64
- * cache: new Map([['key', 'value']]),
65
- * };
66
- *
67
- * const json = serializeProps(props);
68
- * // ν΄λΌμ΄μ–ΈνŠΈλ‘œ 전솑
69
- * ```
70
- */
71
- export function serializeProps(props: Record<string, unknown>): string {
72
- const ctx: SerializeContext = { seen: new Map(), refs: [] };
73
- return JSON.stringify(serialize(props, ctx));
74
- }
75
-
76
- /**
77
- * κ°’ 직렬화 (μž¬κ·€)
78
- */
79
- function serialize(value: unknown, ctx: SerializeContext): unknown {
80
- // null
81
- if (value === null) return null;
82
-
83
- // undefined
84
- if (value === undefined) return TYPE_MARKERS.UNDEFINED;
85
-
86
- // μ›μ‹œν˜•
87
- if (typeof value === "boolean" || typeof value === "number") {
88
- return value;
89
- }
90
-
91
- if (typeof value === "string") {
92
- // νƒ€μž… λ§ˆμ»€μ™€ 좩돌 λ°©μ§€ (첫 λ¬Έμžκ°€ \x00인 경우)
93
- if (value.startsWith("\x00")) {
94
- return "\x00\x00" + value;
95
- }
96
- return value;
97
- }
98
-
99
- if (typeof value === "bigint") {
100
- return TYPE_MARKERS.BIGINT + value.toString();
101
- }
102
-
103
- if (typeof value === "symbol") {
104
- // Symbol은 description만 보쑴
105
- return TYPE_MARKERS.SYMBOL + (value.description ?? "");
106
- }
107
-
108
- // ν•¨μˆ˜λŠ” 직렬화 λΆˆκ°€
109
- if (typeof value === "function") {
110
- console.warn("[Mandu Serialize] Functions cannot be serialized, skipping");
111
- return undefined;
112
- }
113
-
114
- // 객체 μˆœν™˜ μ°Έμ‘° 체크
115
- if (typeof value === "object") {
116
- const existing = ctx.seen.get(value);
117
- if (existing !== undefined) {
118
- return TYPE_MARKERS.REF + existing;
119
- }
120
-
121
- const idx = ctx.refs.length;
122
- ctx.seen.set(value, idx);
123
- ctx.refs.push(value);
124
- }
125
-
126
- // Date
127
- if (value instanceof Date) {
128
- return TYPE_MARKERS.DATE + value.toISOString();
129
- }
130
-
131
- // URL
132
- if (value instanceof URL) {
133
- return TYPE_MARKERS.URL + value.href;
134
- }
135
-
136
- // RegExp
137
- if (value instanceof RegExp) {
138
- return TYPE_MARKERS.REGEXP + value.toString();
139
- }
140
-
141
- // Error
142
- if (value instanceof Error) {
143
- return [
144
- TYPE_MARKERS.ERROR,
145
- value.name,
146
- value.message,
147
- value.stack ?? "",
148
- ];
149
- }
150
-
151
- // Map
152
- if (value instanceof Map) {
153
- const entries: [unknown, unknown][] = [];
154
- for (const [k, v] of value.entries()) {
155
- entries.push([serialize(k, ctx), serialize(v, ctx)]);
156
- }
157
- return [TYPE_MARKERS.MAP, ...entries];
158
- }
159
-
160
- // Set
161
- if (value instanceof Set) {
162
- const items: unknown[] = [];
163
- for (const item of value) {
164
- items.push(serialize(item, ctx));
165
- }
166
- return [TYPE_MARKERS.SET, ...items];
167
- }
168
-
169
- // λ°°μ—΄
170
- if (Array.isArray(value)) {
171
- return value.map((item) => serialize(item, ctx));
172
- }
173
-
174
- // 일반 객체
175
- const result: Record<string, unknown> = {};
176
- for (const [k, v] of Object.entries(value as object)) {
177
- const serialized = serialize(v, ctx);
178
- if (serialized !== undefined) {
179
- result[k] = serialized;
180
- }
181
- }
182
- return result;
183
- }
184
-
185
- // ============================================
186
- // 역직렬화
187
- // ============================================
188
-
189
- /**
190
- * 역직렬화 μ»¨ν…μŠ€νŠΈ (μˆœν™˜ μ°Έμ‘° 볡원)
191
- */
192
- interface DeserializeContext {
193
- refs: unknown[];
194
- }
195
-
196
- /**
197
- * Props 역직렬화
198
- *
199
- * @example
200
- * ```typescript
201
- * // μ„œλ²„μ—μ„œ 받은 JSON
202
- * const json = '{"date":"\x00D2025-01-28T00:00:00.000Z"}';
203
- *
204
- * const props = deserializeProps(json);
205
- * console.log(props.date instanceof Date); // true
206
- * ```
207
- */
208
- export function deserializeProps(json: string): Record<string, unknown> {
209
- const ctx: DeserializeContext = { refs: [] };
210
- const parsed = JSON.parse(json);
211
- return deserialize(parsed, ctx) as Record<string, unknown>;
212
- }
213
-
214
- /**
215
- * κ°’ 역직렬화 (μž¬κ·€)
216
- */
217
- function deserialize(value: unknown, ctx: DeserializeContext): unknown {
218
- // null
219
- if (value === null) return null;
220
-
221
- // λ¬Έμžμ—΄ β†’ νƒ€μž… 마컀 체크
222
- if (typeof value === "string") {
223
- // undefined
224
- if (value === TYPE_MARKERS.UNDEFINED) return undefined;
225
-
226
- // μ΄μŠ€μΌ€μ΄ν”„λœ λ¬Έμžμ—΄ (\x00\x00 β†’ \x00)
227
- if (value.startsWith("\x00\x00")) {
228
- return value.slice(2);
229
- }
230
-
231
- // Date
232
- if (value.startsWith(TYPE_MARKERS.DATE)) {
233
- return new Date(value.slice(2));
234
- }
235
-
236
- // URL
237
- if (value.startsWith(TYPE_MARKERS.URL)) {
238
- return new URL(value.slice(2));
239
- }
240
-
241
- // RegExp
242
- if (value.startsWith(TYPE_MARKERS.REGEXP)) {
243
- const str = value.slice(2);
244
- const match = str.match(/^\/(.*)\/([gimsuy]*)$/);
245
- if (match) {
246
- return new RegExp(match[1], match[2]);
247
- }
248
- return str; // νŒŒμ‹± μ‹€νŒ¨ μ‹œ λ¬Έμžμ—΄ λ°˜ν™˜
249
- }
250
-
251
- // BigInt
252
- if (value.startsWith(TYPE_MARKERS.BIGINT)) {
253
- return BigInt(value.slice(2));
254
- }
255
-
256
- // Symbol
257
- if (value.startsWith(TYPE_MARKERS.SYMBOL)) {
258
- return Symbol(value.slice(2));
259
- }
260
-
261
- // μˆœν™˜ μ°Έμ‘°
262
- if (value.startsWith(TYPE_MARKERS.REF)) {
263
- const idx = parseInt(value.slice(2), 10);
264
- return ctx.refs[idx];
265
- }
266
-
267
- return value;
268
- }
269
-
270
- // μ›μ‹œν˜•
271
- if (typeof value === "boolean" || typeof value === "number") {
272
- return value;
273
- }
274
-
275
- // λ°°μ—΄ β†’ 특수 νƒ€μž… 체크
276
- if (Array.isArray(value)) {
277
- const marker = value[0];
278
-
279
- // Error
280
- if (marker === TYPE_MARKERS.ERROR) {
281
- const [, name, message, stack] = value as [string, string, string, string];
282
- const error = new Error(message);
283
- error.name = name;
284
- if (stack) error.stack = stack;
285
- ctx.refs.push(error);
286
- return error;
287
- }
288
-
289
- // Map
290
- if (marker === TYPE_MARKERS.MAP) {
291
- const map = new Map();
292
- ctx.refs.push(map);
293
- for (let i = 1; i < value.length; i++) {
294
- const [k, v] = value[i] as [unknown, unknown];
295
- map.set(deserialize(k, ctx), deserialize(v, ctx));
296
- }
297
- return map;
298
- }
299
-
300
- // Set
301
- if (marker === TYPE_MARKERS.SET) {
302
- const set = new Set();
303
- ctx.refs.push(set);
304
- for (let i = 1; i < value.length; i++) {
305
- set.add(deserialize(value[i], ctx));
306
- }
307
- return set;
308
- }
309
-
310
- // 일반 λ°°μ—΄
311
- const arr: unknown[] = [];
312
- ctx.refs.push(arr);
313
- for (const item of value) {
314
- arr.push(deserialize(item, ctx));
315
- }
316
- return arr;
317
- }
318
-
319
- // 일반 객체
320
- if (typeof value === "object") {
321
- const obj: Record<string, unknown> = {};
322
- ctx.refs.push(obj);
323
- for (const [k, v] of Object.entries(value)) {
324
- obj[k] = deserialize(v, ctx);
325
- }
326
- return obj;
327
- }
328
-
329
- return value;
330
- }
331
-
332
- // ============================================
333
- // μœ ν‹Έλ¦¬ν‹°
334
- // ============================================
335
-
336
- /**
337
- * 직렬화 κ°€λŠ₯ μ—¬λΆ€ 체크
338
- */
339
- export function isSerializable(value: unknown): boolean {
340
- if (value === null || value === undefined) return true;
341
-
342
- const type = typeof value;
343
- if (type === "boolean" || type === "number" || type === "string" || type === "bigint") {
344
- return true;
345
- }
346
-
347
- if (type === "function" || type === "symbol") {
348
- return false;
349
- }
350
-
351
- if (value instanceof Date || value instanceof URL || value instanceof RegExp) {
352
- return true;
353
- }
354
-
355
- if (value instanceof Map || value instanceof Set) {
356
- return true;
357
- }
358
-
359
- if (Array.isArray(value)) {
360
- return value.every(isSerializable);
361
- }
362
-
363
- if (type === "object") {
364
- return Object.values(value as object).every(isSerializable);
365
- }
366
-
367
- return false;
368
- }
369
-
370
- /**
371
- * SSRμ—μ„œ ν΄λΌμ΄μ–ΈνŠΈλ‘œ props μ „λ‹¬μš© 슀크립트 생성
372
- */
373
- export function generatePropsScript(
374
- islandId: string,
375
- props: Record<string, unknown>
376
- ): string {
377
- const json = serializeProps(props);
378
- const escaped = json
379
- .replace(/</g, "\\u003c")
380
- .replace(/>/g, "\\u003e")
381
- .replace(/&/g, "\\u0026");
382
-
383
- return `<script type="application/json" data-mandu-props="${islandId}">${escaped}</script>`;
384
- }
385
-
386
- /**
387
- * ν΄λΌμ΄μ–ΈνŠΈμ—μ„œ props 슀크립트 νŒŒμ‹±
388
- */
389
- export function parsePropsScript(islandId: string): Record<string, unknown> | null {
390
- if (typeof document === "undefined") return null;
391
-
392
- const script = document.querySelector(
393
- `script[data-mandu-props="${islandId}"]`
394
- ) as HTMLScriptElement | null;
395
-
396
- if (!script?.textContent) return null;
397
-
398
- try {
399
- return deserializeProps(script.textContent);
400
- } catch (err) {
401
- console.error(`[Mandu] Failed to parse props for island ${islandId}:`, err);
402
- return null;
403
- }
404
- }
1
+ /**
2
+ * Mandu props serialization public API.
3
+ *
4
+ * Core serialization/deserialization lives in `props-serialization.ts` so the
5
+ * browser runtime and server render path share exactly one wire format.
6
+ */
7
+
8
+ import { deserializeProps, serializeProps } from "./props-serialization";
9
+
10
+ export {
11
+ deserializeProps,
12
+ isSerializable,
13
+ serializeProps,
14
+ } from "./props-serialization";
15
+
16
+ /**
17
+ * SSRμ—μ„œ ν΄λΌμ΄μ–ΈνŠΈλ‘œ props μ „λ‹¬μš© 슀크립트 생성
18
+ */
19
+ export function generatePropsScript(
20
+ islandId: string,
21
+ props: Record<string, unknown>
22
+ ): string {
23
+ const json = serializeProps(props);
24
+ const escaped = json
25
+ .replace(/</g, "\\u003c")
26
+ .replace(/>/g, "\\u003e")
27
+ .replace(/&/g, "\\u0026");
28
+
29
+ return `<script type="application/json" data-mandu-props="${islandId}">${escaped}</script>`;
30
+ }
31
+
32
+ /**
33
+ * ν΄λΌμ΄μ–ΈνŠΈμ—μ„œ props 슀크립트 νŒŒμ‹±
34
+ */
35
+ export function parsePropsScript(islandId: string): Record<string, unknown> | null {
36
+ if (typeof document === "undefined") return null;
37
+
38
+ const script = document.querySelector(
39
+ `script[data-mandu-props="${islandId}"]`
40
+ ) as HTMLScriptElement | null;
41
+
42
+ if (!script?.textContent) return null;
43
+
44
+ try {
45
+ return deserializeProps(script.textContent);
46
+ } catch (err) {
47
+ console.error(`[Mandu] Failed to parse props for island ${islandId}:`, err);
48
+ return null;
49
+ }
50
+ }
@@ -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,134 @@ 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("treats a missing bundle manifest as an error when routes declare compiler-owned boundaries", 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
+ const result = await checkClientBoundaryManifests(rootDir);
470
+ expect(result.ok).toBe(false);
471
+ expect(result.severity).toBe("error");
472
+ expect(result.message).toMatch(/bundle manifest is missing/);
473
+ });
474
+
475
+ it("passes when route and bundle boundary manifests line up", async () => {
476
+ await writeFile(rootDir, ".mandu/routes.manifest.json", JSON.stringify({
477
+ routes: [
478
+ {
479
+ id: "home",
480
+ boundaries: [{ id: "home--0", routeId: "home", module: "src/client/Home.client.tsx", exportName: "Home" }],
481
+ },
482
+ ],
483
+ }));
484
+ await writeFile(rootDir, ".mandu/client/home--0.boundary.js", "export default function Home() {}\n");
485
+ await writeFile(rootDir, ".mandu/manifest.json", JSON.stringify({
486
+ version: 1,
487
+ buildTime: "x",
488
+ env: "production",
489
+ bundles: {},
490
+ boundaries: {
491
+ "home--0": {
492
+ route: "home",
493
+ js: "/.mandu/client/home--0.boundary.js",
494
+ module: "src/client/Home.client.tsx",
495
+ exportName: "Home",
496
+ },
497
+ },
498
+ shared: { runtime: "/.mandu/client/_runtime.js", vendor: "/.mandu/client/_react.js" },
499
+ }));
500
+ const result = await checkClientBoundaryManifests(rootDir);
501
+ expect(result.ok).toBe(true);
502
+ expect(result.details?.boundaryCount).toBe(1);
503
+ });
504
+ });
505
+
506
+ // ──────────────────────────────────────────────────────────────────
507
+ // aggregator
508
+ // ──────────────────────────────────────────────────────────────────
395
509
 
396
510
  describe("runExtendedDiagnose", () => {
397
511
  let rootDir: string;
398
512
  beforeEach(async () => { rootDir = await mkTmpRoot(); });
399
513
  afterEach(async () => { await fs.rm(rootDir, { recursive: true, force: true }); });
400
514
 
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);
515
+ it("runs all 8 extended checks and returns a structured report", async () => {
516
+ const report = await runExtendedDiagnose(rootDir);
517
+ // F42/F45 added `client_boundary_manifests` β€” total is now 8.
518
+ expect(report.summary.total).toBe(8);
405
519
  // manifest is missing β†’ at least one error
406
520
  expect(report.healthy).toBe(false);
407
521
  expect(report.errorCount).toBeGreaterThanOrEqual(1);
@@ -410,10 +524,11 @@ describe("runExtendedDiagnose", () => {
410
524
  expect(rules).toContain("prerender_pollution");
411
525
  expect(rules).toContain("cloneelement_warnings");
412
526
  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
- });
527
+ expect(rules).toContain("package_export_gaps");
528
+ expect(rules).toContain("nested_internal_core");
529
+ expect(rules).toContain("client_boundary_manifests");
530
+ expect(rules).toContain("a11y_hints");
531
+ });
417
532
 
418
533
  it("returns healthy=true when all checks pass (production manifest, no gaps)", async () => {
419
534
  await writeFile(rootDir, ".mandu/manifest.json", JSON.stringify({