@mandujs/core 0.53.2 → 0.53.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.53.2",
3
+ "version": "0.53.3",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -17,6 +17,7 @@ import {
17
17
  checkCloneElementWarnings,
18
18
  checkDevArtifactsInProd,
19
19
  checkPackageExportGaps,
20
+ checkNestedInternalCore,
20
21
  } from "../checks";
21
22
  import { runExtendedDiagnose, buildReport } from "../run";
22
23
 
@@ -319,6 +320,75 @@ describe("checkPackageExportGaps", () => {
319
320
  });
320
321
  });
321
322
 
323
+ // ──────────────────────────────────────────────────────────────────
324
+ // nested_internal_core (#261)
325
+ // ──────────────────────────────────────────────────────────────────
326
+
327
+ describe("checkNestedInternalCore", () => {
328
+ let rootDir: string;
329
+ beforeEach(async () => { rootDir = await mkTmpRoot(); });
330
+ afterEach(async () => { await fs.rm(rootDir, { recursive: true, force: true }); });
331
+
332
+ async function seedHoistedCore(version: string): Promise<void> {
333
+ await writeFile(rootDir, "node_modules/@mandujs/core/package.json", JSON.stringify({
334
+ name: "@mandujs/core", version,
335
+ }));
336
+ }
337
+
338
+ async function seedNestedCore(parent: string, version: string): Promise<void> {
339
+ await writeFile(
340
+ rootDir,
341
+ `node_modules/@mandujs/${parent}/node_modules/@mandujs/core/package.json`,
342
+ JSON.stringify({ name: "@mandujs/core", version }),
343
+ );
344
+ }
345
+
346
+ it("skips when hoisted @mandujs/core is not installed", async () => {
347
+ const result = await checkNestedInternalCore(rootDir);
348
+ expect(result.ok).toBe(true);
349
+ expect(result.details?.skipped).toBe(true);
350
+ });
351
+
352
+ it("passes when no nested core exists", async () => {
353
+ await seedHoistedCore("0.53.2");
354
+ // Seed a sibling without nested core
355
+ await writeFile(rootDir, "node_modules/@mandujs/mcp/package.json", JSON.stringify({
356
+ name: "@mandujs/mcp", version: "0.36.2",
357
+ }));
358
+ const result = await checkNestedInternalCore(rootDir);
359
+ expect(result.ok).toBe(true);
360
+ expect(result.details?.scannedSiblings).toBe(1);
361
+ });
362
+
363
+ it("passes when nested core matches hoisted version exactly", async () => {
364
+ await seedHoistedCore("0.53.2");
365
+ await seedNestedCore("mcp", "0.53.2");
366
+ const result = await checkNestedInternalCore(rootDir);
367
+ expect(result.ok).toBe(true);
368
+ });
369
+
370
+ it("flags a stale nested core that shadows hoisted (#261 repro)", async () => {
371
+ await seedHoistedCore("0.53.2");
372
+ await seedNestedCore("mcp", "0.47.0");
373
+ const result = await checkNestedInternalCore(rootDir);
374
+ expect(result.ok).toBe(false);
375
+ expect(result.severity).toBe("error");
376
+ expect(result.message).toMatch(/@mandujs\/mcp/);
377
+ expect(result.message).toMatch(/0\.47\.0/);
378
+ expect(result.suggestion).toMatch(/rm -rf node_modules\/@mandujs\/mcp\/node_modules/);
379
+ expect(result.details?.mismatchCount).toBe(1);
380
+ });
381
+
382
+ it("reports every mismatched sibling, not just one", async () => {
383
+ await seedHoistedCore("0.53.2");
384
+ await seedNestedCore("mcp", "0.47.0");
385
+ await seedNestedCore("ate", "0.50.0");
386
+ const result = await checkNestedInternalCore(rootDir);
387
+ expect(result.ok).toBe(false);
388
+ expect(result.details?.mismatchCount).toBe(2);
389
+ });
390
+ });
391
+
322
392
  // ──────────────────────────────────────────────────────────────────
323
393
  // aggregator
324
394
  // ──────────────────────────────────────────────────────────────────
@@ -328,10 +398,10 @@ describe("runExtendedDiagnose", () => {
328
398
  beforeEach(async () => { rootDir = await mkTmpRoot(); });
329
399
  afterEach(async () => { await fs.rm(rootDir, { recursive: true, force: true }); });
330
400
 
331
- it("runs all 6 extended checks and returns a structured report", async () => {
401
+ it("runs all 7 extended checks and returns a structured report", async () => {
332
402
  const report = await runExtendedDiagnose(rootDir);
333
- // Phase 18.χ added `a11y_hints` — total is now 6.
334
- expect(report.summary.total).toBe(6);
403
+ // #261 added `nested_internal_core` — total is now 7.
404
+ expect(report.summary.total).toBe(7);
335
405
  // manifest is missing → at least one error
336
406
  expect(report.healthy).toBe(false);
337
407
  expect(report.errorCount).toBeGreaterThanOrEqual(1);
@@ -341,6 +411,7 @@ describe("runExtendedDiagnose", () => {
341
411
  expect(rules).toContain("cloneelement_warnings");
342
412
  expect(rules).toContain("dev_artifacts_in_prod");
343
413
  expect(rules).toContain("package_export_gaps");
414
+ expect(rules).toContain("nested_internal_core");
344
415
  expect(rules).toContain("a11y_hints");
345
416
  });
346
417
 
@@ -718,3 +718,115 @@ export async function checkA11yHints(rootDir: string): Promise<DiagnoseCheckResu
718
718
  },
719
719
  };
720
720
  }
721
+
722
+ // ────────────────────────────────────────────────────────────────────────
723
+ // 7. nested_internal_core (#261)
724
+ // ────────────────────────────────────────────────────────────────────────
725
+
726
+ interface NestedCoreSite {
727
+ parentPackage: string;
728
+ nestedVersion: string;
729
+ relativePath: string;
730
+ }
731
+
732
+ async function readPackageVersion(pkgJsonPath: string): Promise<string | null> {
733
+ try {
734
+ const raw = await fs.readFile(pkgJsonPath, "utf-8");
735
+ const parsed = JSON.parse(raw) as { version?: string };
736
+ return typeof parsed.version === "string" ? parsed.version : null;
737
+ } catch {
738
+ return null;
739
+ }
740
+ }
741
+
742
+ async function listMandujsSiblings(rootDir: string): Promise<string[]> {
743
+ const dir = path.join(rootDir, "node_modules", "@mandujs");
744
+ try {
745
+ const entries = (await fs.readdir(dir, { withFileTypes: true })) as Dirent[];
746
+ return entries
747
+ .filter((e) => e.isDirectory() && e.name !== "core")
748
+ .map((e) => e.name);
749
+ } catch {
750
+ return [];
751
+ }
752
+ }
753
+
754
+ /**
755
+ * #261 check 7: detect a stale `@mandujs/core` nested inside another
756
+ * `@mandujs/*` package's `node_modules`.
757
+ *
758
+ * Symptom: `bunx @mandujs/mcp` fails with `Cannot find module
759
+ * @mandujs/core/<subpath>` even though the project has the latest
760
+ * `@mandujs/core` hoisted at the top level. Cause: a previous install
761
+ * left an older core nested under `@mandujs/<sibling>/node_modules`,
762
+ * which wins module resolution from inside that sibling's code, and the
763
+ * older core's `exports` map lacks the subpath the sibling now imports.
764
+ *
765
+ * This check walks `node_modules/@mandujs/*` siblings, looks for a
766
+ * nested `node_modules/@mandujs/core/package.json`, and compares the
767
+ * version to the hoisted core. A mismatch is reported as `error`
768
+ * (boot-breaking on the user's machine) with a copy-pastable fix.
769
+ */
770
+ export async function checkNestedInternalCore(rootDir: string): Promise<DiagnoseCheckResult> {
771
+ const hoistedPath = path.join(rootDir, "node_modules", "@mandujs", "core", "package.json");
772
+ const hoistedVersion = await readPackageVersion(hoistedPath);
773
+
774
+ if (!hoistedVersion) {
775
+ return {
776
+ ok: true,
777
+ rule: "nested_internal_core",
778
+ message: "@mandujs/core not installed at the project root — skipping nested-version check.",
779
+ details: { skipped: true },
780
+ };
781
+ }
782
+
783
+ const siblings = await listMandujsSiblings(rootDir);
784
+ const mismatches: NestedCoreSite[] = [];
785
+
786
+ for (const sibling of siblings) {
787
+ const nestedPkgJson = path.join(
788
+ rootDir,
789
+ "node_modules",
790
+ "@mandujs",
791
+ sibling,
792
+ "node_modules",
793
+ "@mandujs",
794
+ "core",
795
+ "package.json",
796
+ );
797
+ const nestedVersion = await readPackageVersion(nestedPkgJson);
798
+ if (!nestedVersion) continue;
799
+ if (nestedVersion === hoistedVersion) continue;
800
+ mismatches.push({
801
+ parentPackage: `@mandujs/${sibling}`,
802
+ nestedVersion,
803
+ relativePath: path.relative(rootDir, nestedPkgJson),
804
+ });
805
+ }
806
+
807
+ if (mismatches.length === 0) {
808
+ return {
809
+ ok: true,
810
+ rule: "nested_internal_core",
811
+ message: `No stale nested @mandujs/core found (hoisted: ${hoistedVersion}, scanned ${siblings.length} sibling(s)).`,
812
+ details: { hoistedVersion, scannedSiblings: siblings.length },
813
+ };
814
+ }
815
+
816
+ const first = mismatches[0];
817
+ const fixCommand = `rm -rf node_modules/${first.parentPackage}/node_modules`;
818
+ return {
819
+ ok: false,
820
+ rule: "nested_internal_core",
821
+ severity: "error",
822
+ message:
823
+ `${mismatches.length} stale nested @mandujs/core install(s) shadow the hoisted ${hoistedVersion}. ` +
824
+ `First: ${first.parentPackage} pinned to ${first.nestedVersion} (${first.relativePath}).`,
825
+ suggestion: `Run \`${fixCommand}\` and re-test \`bunx @mandujs/mcp\`, or remove node_modules and bun.lock entirely and \`bun install\`.`,
826
+ details: {
827
+ hoistedVersion,
828
+ mismatchCount: mismatches.length,
829
+ mismatches,
830
+ },
831
+ };
832
+ }
@@ -11,6 +11,7 @@ export {
11
11
  checkCloneElementWarnings,
12
12
  checkDevArtifactsInProd,
13
13
  checkPackageExportGaps,
14
+ checkNestedInternalCore,
14
15
  checkA11yHints,
15
16
  } from "./checks";
16
17
  export { EXTENDED_CHECKS, runExtendedDiagnose, buildReport } from "./run";
@@ -12,6 +12,7 @@ import {
12
12
  checkCloneElementWarnings,
13
13
  checkDevArtifactsInProd,
14
14
  checkPackageExportGaps,
15
+ checkNestedInternalCore,
15
16
  checkA11yHints,
16
17
  } from "./checks";
17
18
 
@@ -30,6 +31,7 @@ export const EXTENDED_CHECKS = [
30
31
  { name: "cloneelement_warnings", run: checkCloneElementWarnings },
31
32
  { name: "dev_artifacts_in_prod", run: checkDevArtifactsInProd },
32
33
  { name: "package_export_gaps", run: checkPackageExportGaps },
34
+ { name: "nested_internal_core", run: checkNestedInternalCore },
33
35
  { name: "a11y_hints", run: checkA11yHints },
34
36
  ] as const;
35
37
 
@@ -772,10 +772,11 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
772
772
  ? generateFastRefreshPreambleTag(isDev, bundleManifest, resolvedCspNonce)
773
773
  : "";
774
774
 
775
- // Issue #191 — DevTools 번들 (~1.15 MB) 주입 결정.
776
- // - 기본: island 하나라도 있을 때만 주입. Pure-SSR 페이지는 0 bytes 다운로드.
777
- // - `devtools === true` → 강제 주입 (SSR-only 프로젝트에서 Kitchen panel 원할 때)
778
- // - `devtools === false` → 강제 스킵 (island 프로젝트에서도 Kitchen 비활성화)
775
+ // Issue #191 + #259 — DevTools 번들 (~1.15 MB) 주입 결정.
776
+ // - 기본 (dev only): 항상 주입. SSR-only 랜딩에서도 Kitchen 패널 사용 가능.
777
+ // - `devtools === true` → 강제 주입 (의미 변화 없음)
778
+ // - `devtools === false` → 강제 스킵 (필요 1.15 MB 절약)
779
+ // 프로덕션 빌드는 `isDev` 가드로 0 bytes 보장.
779
780
  // Cache-bust 은 `manifest.buildTime` 우선, 없으면 `Date.now()`.
780
781
  let devtoolsScript = "";
781
782
  if (isDev && shouldInjectDevtools(devtools, bundleManifest)) {
@@ -1039,27 +1040,27 @@ window.__MANDU_HMR_PORT__ = ${hmrPort};
1039
1040
  }
1040
1041
 
1041
1042
  /**
1042
- * Issue #191 — Determine whether the dev-only `_devtools.js` bundle
1043
- * (~1.15 MB React dev runtime + Kitchen panel) should be injected
1044
- * into the HTML response.
1043
+ * Issue #191 + #259 — Determine whether the dev-only `_devtools.js`
1044
+ * bundle (~1.15 MB React dev runtime + Kitchen panel) should be injected
1045
+ * into the HTML response. The caller gates with `isDev`, so this only
1046
+ * affects dev-mode responses (prod always skips, regardless of the
1047
+ * decision below).
1045
1048
  *
1046
1049
  * Decision table (`devtools` option × manifest shape):
1047
1050
  *
1048
- * | `devtools` | hasIslands | inject? | rationale |
1049
- * |-------------|------------|---------|-----------------------------|
1050
- * | `true` | any | YES | explicit opt-in |
1051
- * | `false` | any | NO | explicit opt-out |
1052
- * | `undefined` | true | YES | default, hydration runtime |
1053
- * | `undefined` | false | NO | pure-SSR save 1.15 MB |
1054
- * | `undefined` | no manifest| NO | nothing to hydrate anyway |
1051
+ * | `devtools` | hasIslands | inject? | rationale |
1052
+ * |-------------|------------|---------|------------------------------|
1053
+ * | `true` | any | YES | explicit opt-in |
1054
+ * | `false` | any | NO | explicit opt-out |
1055
+ * | `undefined` | true | YES | default, hydration runtime |
1056
+ * | `undefined` | false | YES | dev DX: Kitchen on SSR pages |
1057
+ * | `undefined` | no manifest| YES | dev DX: Kitchen pre-build |
1055
1058
  *
1056
- * `hasIslands` is derived from the existing manifest shape rather than
1057
- * a new field, so no bundler-side change is required:
1058
- * - `manifest.islands` is populated only when per-island code
1059
- * splitting produced at least one bundle (build.ts:1654).
1060
- * - `manifest.bundles` entries exist only for routes where
1061
- * `needsHydration()` is true (build.ts:70 filter).
1062
- * Either non-empty ⇒ some route on this server hydrates ⇒ devtools useful.
1059
+ * #259 reverted the original #191 default (skip when no islands): SSR-only
1060
+ * landing/marketing pages are the exact place where Kitchen network/error
1061
+ * panels are most needed, and the 1.15 MB cost only applies in dev — prod
1062
+ * builds never emit `_devtools.js`. Users who explicitly want the old
1063
+ * behavior on a per-app basis can still set `dev.devtools: false`.
1063
1064
  *
1064
1065
  * @internal Exported via `_testOnly_shouldInjectDevtools` below so
1065
1066
  * `tests/runtime/devtools-inject.test.ts` can table-test the matrix
@@ -1067,19 +1068,11 @@ window.__MANDU_HMR_PORT__ = ${hmrPort};
1067
1068
  */
1068
1069
  function shouldInjectDevtools(
1069
1070
  devtools: boolean | undefined,
1070
- manifest: BundleManifest | undefined,
1071
+ _manifest: BundleManifest | undefined,
1071
1072
  ): boolean {
1072
- // Explicit overrides take absolute precedence.
1073
1073
  if (devtools === true) return true;
1074
1074
  if (devtools === false) return false;
1075
-
1076
- // Default behavior: inject only when there is at least one island.
1077
- if (!manifest) return false;
1078
- const hasIslandsMap =
1079
- manifest.islands && Object.keys(manifest.islands).length > 0;
1080
- const hasBundles =
1081
- manifest.bundles && Object.keys(manifest.bundles).length > 0;
1082
- return Boolean(hasIslandsMap || hasBundles);
1075
+ return true;
1083
1076
  }
1084
1077
 
1085
1078
  /**
@@ -80,10 +80,10 @@ export interface StreamingError {
80
80
  export interface StreamingMetrics {
81
81
  /** Shell ready까지 걸린 시간 (ms) */
82
82
  shellReadyTime: number;
83
- /** All ready까지 걸린 시간 (ms) */
84
- allReadyTime: number;
85
- /** Deferred chunk 개수 */
86
- deferredChunkCount: number;
83
+ /** All ready까지 걸린 시간 (ms) */
84
+ allReadyTime: number;
85
+ /** Deferred chunk 개수 */
86
+ deferredChunkCount: number;
87
87
  /** 에러 발생 여부 */
88
88
  hasError: boolean;
89
89
  /** 시작 시간 */
@@ -202,7 +202,7 @@ export interface StreamingSSROptions {
202
202
  }
203
203
 
204
204
  /**
205
- * Issue #191 — Streaming-SSR mirror of `ssr.ts:shouldInjectDevtools`.
205
+ * Issue #191 + #259 — Streaming-SSR mirror of `ssr.ts:shouldInjectDevtools`.
206
206
  * Kept in sync manually (tiny pure function, not worth a cross-module
207
207
  * runtime import — the ssr.ts → streaming-ssr.ts re-export direction
208
208
  * means a circular import here would force a refactor of the whole
@@ -211,16 +211,11 @@ export interface StreamingSSROptions {
211
211
  */
212
212
  function shouldInjectDevtoolsStreaming(
213
213
  devtools: boolean | undefined,
214
- manifest: BundleManifest | undefined,
214
+ _manifest: BundleManifest | undefined,
215
215
  ): boolean {
216
216
  if (devtools === true) return true;
217
217
  if (devtools === false) return false;
218
- if (!manifest) return false;
219
- const hasIslandsMap =
220
- manifest.islands && Object.keys(manifest.islands).length > 0;
221
- const hasBundles =
222
- manifest.bundles && Object.keys(manifest.bundles).length > 0;
223
- return Boolean(hasIslandsMap || hasBundles);
218
+ return true;
224
219
  }
225
220
 
226
221
  /**
@@ -778,10 +773,10 @@ function generateHTMLTailContent(options: StreamingSSROptions): string {
778
773
  scripts.push(generateHMRScript(hmrPort));
779
774
  }
780
775
 
781
- // 11. Issue #191 — DevTools 번들 (~1.15 MB) 주입 결정.
782
- // - 기본: manifest island/bundle 있을 때만 주입 (pure-SSR 페이지는 스킵).
783
- // - `devtools === true` → 강제 주입 (SSR-only 프로젝트에서 Kitchen 원할 ).
784
- // - `devtools === false` 강제 스킵.
776
+ // 11. Issue #191 + #259 — DevTools 번들 (~1.15 MB) 주입 결정.
777
+ // - 기본 (dev only): 항상 주입. SSR-only 랜딩에서도 Kitchen 사용 가능.
778
+ // - `devtools === false` → 강제 스킵 (필요 1.15 MB 절약).
779
+ // - 프로덕션 빌드는 `isDev` 가드로 0 bytes 보장.
785
780
  // - Cache-bust (`?v=buildTime`) 로 HMR 후 stale 방지.
786
781
  if (isDev && shouldInjectDevtoolsStreaming(devtools, bundleManifest)) {
787
782
  scripts.push(generateStreamingDevtoolsScript(bundleManifest));
@@ -1001,9 +996,9 @@ export async function renderToStream(
1001
996
 
1002
997
  // 메트릭 수집
1003
998
  const metrics: StreamingMetrics = {
1004
- shellReadyTime: 0,
1005
- allReadyTime: 0,
1006
- deferredChunkCount: 0,
999
+ shellReadyTime: 0,
1000
+ allReadyTime: 0,
1001
+ deferredChunkCount: 0,
1007
1002
  hasError: false,
1008
1003
  startTime: Date.now(),
1009
1004
  };
@@ -1379,9 +1374,9 @@ export async function renderWithDeferredData(
1379
1374
  const encoder = new TextEncoder();
1380
1375
  const startTime = Date.now();
1381
1376
 
1382
- // 준비된 deferred 스크립트를 담을 배열 (mutable)
1383
- const readyScripts: string[] = [];
1384
- let allDeferredSettled = false;
1377
+ // 준비된 deferred 스크립트를 담을 배열 (mutable)
1378
+ const readyScripts: string[] = [];
1379
+ let allDeferredSettled = false;
1385
1380
 
1386
1381
  // 1. Deferred promises 병렬 시작 (막지 않음!)
1387
1382
  const deferredEntries = Object.entries(deferredPromises);
@@ -1395,11 +1390,11 @@ export async function renderWithDeferredData(
1395
1390
  );
1396
1391
  const data = await Promise.race([promise, timeoutPromise]);
1397
1392
 
1398
- // 스크립트 생성 및 추가
1399
- const script = generateDeferredDataScript(routeId, key, data);
1400
- readyScripts.push(script);
1401
-
1402
- if (isDev) {
1393
+ // 스크립트 생성 및 추가
1394
+ const script = generateDeferredDataScript(routeId, key, data);
1395
+ readyScripts.push(script);
1396
+
1397
+ if (isDev) {
1403
1398
  console.log(`[Mandu Streaming] Deferred ready: ${key} (${Date.now() - startTime}ms)`);
1404
1399
  }
1405
1400
  } catch (error) {
@@ -1473,22 +1468,22 @@ export async function renderWithDeferredData(
1473
1468
 
1474
1469
  // 최종 메트릭 보고 (injectedCount가 실제 메트릭)
1475
1470
  if (onMetrics && baseMetrics) {
1476
- onMetrics({
1477
- ...baseMetrics,
1478
- deferredChunkCount: injectedCount,
1479
- allReadyTime: Date.now() - startTime,
1480
- });
1471
+ onMetrics({
1472
+ ...baseMetrics,
1473
+ deferredChunkCount: injectedCount,
1474
+ allReadyTime: Date.now() - startTime,
1475
+ });
1481
1476
  }
1482
1477
 
1483
1478
  controller.close();
1484
1479
  } catch (error) {
1485
1480
  controller.error(error);
1486
1481
  }
1487
- },
1488
- cancel() {
1489
- void reader.cancel();
1490
- },
1491
- });
1482
+ },
1483
+ cancel() {
1484
+ void reader.cancel();
1485
+ },
1486
+ });
1492
1487
 
1493
1488
  // Phase 7.2 R1 Agent C (H1): if the base stream produced a CSP
1494
1489
  // nonce during shell emission, forward the matching header on the