@mandujs/core 0.41.2 → 0.43.0

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 (88) hide show
  1. package/package.json +21 -4
  2. package/src/auth/__tests__/login.test.ts +420 -419
  3. package/src/auth/__tests__/reset.test.ts +296 -296
  4. package/src/brain/adapters/anthropic-oauth.ts +421 -420
  5. package/src/brain/adapters/index.ts +2 -1
  6. package/src/brain/adapters/ollama.ts +1 -1
  7. package/src/brain/adapters/openai-oauth.ts +534 -533
  8. package/src/brain/brain.ts +2 -1
  9. package/src/brain/redactor.ts +196 -196
  10. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
  11. package/src/bundler/__tests__/cold-start.test.ts +504 -504
  12. package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
  13. package/src/bundler/__tests__/hdr.test.ts +1 -1
  14. package/src/bundler/analyzer.ts +958 -958
  15. package/src/bundler/build.ts +104 -14
  16. package/src/bundler/dev.ts +125 -0
  17. package/src/bundler/hmr-types.ts +1 -0
  18. package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
  19. package/src/bundler/plugins/index.ts +14 -0
  20. package/src/bundler/plugins/react-compiler-lint.ts +253 -0
  21. package/src/bundler/plugins/react-compiler.ts +162 -0
  22. package/src/bundler/types.ts +12 -0
  23. package/src/change/integrity.ts +2 -1
  24. package/src/client/index.ts +10 -0
  25. package/src/client/island.ts +38 -11
  26. package/src/client/router.ts +6 -1
  27. package/src/config/mandu.ts +57 -0
  28. package/src/config/validate.ts +42 -0
  29. package/src/content/collection.ts +844 -809
  30. package/src/content/content-layer.ts +316 -314
  31. package/src/content/content.test.ts +433 -433
  32. package/src/content/digest.ts +133 -133
  33. package/src/content/generate-types.ts +168 -168
  34. package/src/content/index.ts +6 -1
  35. package/src/content/llms-txt.ts +277 -277
  36. package/src/contract/define.ts +474 -474
  37. package/src/contract/route-helpers.ts +2 -1
  38. package/src/contract/zod-utils.ts +158 -155
  39. package/src/db/index.ts +513 -513
  40. package/src/desktop/__tests__/smoke.test.ts +100 -100
  41. package/src/desktop/webview-fallback.ts +583 -583
  42. package/src/desktop/window.ts +3 -1
  43. package/src/dev-error-overlay/overlay-client.ts +300 -300
  44. package/src/devtools/ai/mcp-connector.ts +499 -498
  45. package/src/devtools/client/components/kitchen-root.tsx +7 -2
  46. package/src/email/resend.ts +163 -163
  47. package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
  48. package/src/guard/ast-analyzer.ts +806 -806
  49. package/src/guard/graph.ts +898 -898
  50. package/src/guard/index.ts +16 -0
  51. package/src/guard/statistics.ts +578 -578
  52. package/src/guard/tsgolint-bridge.ts +512 -0
  53. package/src/i18n/locale-resolver.ts +214 -214
  54. package/src/id/__tests__/id.test.ts +120 -120
  55. package/src/intent/index.ts +321 -321
  56. package/src/island/index.ts +39 -23
  57. package/src/kitchen/api/contract-api.ts +15 -8
  58. package/src/kitchen/kitchen-ui.ts +2137 -2137
  59. package/src/lockfile/index.ts +3 -2
  60. package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
  61. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
  62. package/src/middleware/secure/index.ts +417 -417
  63. package/src/observability/event-bus.ts +2 -2
  64. package/src/observability/metrics.ts +334 -334
  65. package/src/observability/tracing.ts +694 -694
  66. package/src/openapi/generator.ts +1 -1
  67. package/src/perf/user-marks.ts +553 -553
  68. package/src/plugins/registry.ts +387 -387
  69. package/src/resource/ddl/diff.ts +392 -392
  70. package/src/resource/ddl/snapshot.ts +448 -447
  71. package/src/resource/generator-schema.ts +477 -476
  72. package/src/resource/parser.ts +4 -2
  73. package/src/resource/schema.ts +1 -1
  74. package/src/router/fs-patterns.ts +422 -422
  75. package/src/runtime/fast-refresh-types.ts +126 -128
  76. package/src/runtime/image-handler.ts +206 -195
  77. package/src/runtime/router.test.ts +476 -476
  78. package/src/runtime/security.ts +155 -155
  79. package/src/runtime/server.ts +36 -19
  80. package/src/runtime/session-key.ts +328 -328
  81. package/src/scheduler/__tests__/scheduler.test.ts +514 -514
  82. package/src/seo/resolve/index.ts +353 -353
  83. package/src/spec/load.ts +1 -1
  84. package/src/testing/reporter.ts +676 -676
  85. package/src/testing/server.ts +196 -196
  86. package/src/testing/snapshot.ts +444 -444
  87. package/src/utils/__tests__/lru-cache.test.ts +186 -186
  88. package/src/utils/bun.ts +8 -8
@@ -58,6 +58,26 @@ function manduDefaultPlugins(options: BundlerOptions): BunPlugin[] {
58
58
  return defaults;
59
59
  }
60
60
 
61
+ /**
62
+ * Client-path plugin composition — defaults + (optionally) React
63
+ * Compiler. Only the islands / `"use client"` / partial builds call
64
+ * this; SSR builds use `manduDefaultPlugins()` directly because React
65
+ * Compiler offers zero benefit for one-shot HTML rendering.
66
+ */
67
+ function manduClientPlugins(options: BundlerOptions): BunPlugin[] {
68
+ const base = manduDefaultPlugins(options);
69
+ if (options.reactCompiler?.enabled !== true) return base;
70
+ // Lazy import to avoid pulling the react-compiler module into every
71
+ // build graph — SSR / non-client paths never touch this branch.
72
+ const { reactCompiler } = require("./plugins/react-compiler") as typeof import("./plugins/react-compiler");
73
+ return [
74
+ ...base,
75
+ reactCompiler({
76
+ reactCompilerConfig: options.reactCompiler.compilerConfig,
77
+ }),
78
+ ];
79
+ }
80
+
61
81
  /**
62
82
  * Scan for *.island.tsx / *.island.ts files across hydrated route directories.
63
83
  *
@@ -83,30 +103,44 @@ async function scanIslandFiles(routes: RouteSpec[], rootDir: string): Promise<Is
83
103
  const entries: IslandFileEntry[] = [];
84
104
  const seenDirs = new Set<string>();
85
105
 
86
- for (const route of routes) {
87
- // Defensive guard see the block comment above for rationale. Without
88
- // this, a hypothetical caller passing `manifest.routes` directly would
89
- // readdir every page route, including pure-SSR ones.
90
- if (!needsHydration(route)) continue;
106
+ // Sub-folders under a route directory that the scanner also descends into,
107
+ // one level only. Convention-driven (not configurable) so the scan cost
108
+ // stays predictable. `_components` is the Mandu equivalent of the widely
109
+ // used Next.js "private folder" convention leading underscore signals
110
+ // "not a route", which is exactly the place users drop page-local island
111
+ // sources. Keep this list short; every name adds one `readdir` per route.
112
+ const ISLAND_SUBDIRS = ["_components", "_islands"] as const;
91
113
 
92
- const dir = path.dirname(path.join(rootDir, route.componentModule ?? route.module));
93
- if (seenDirs.has(dir)) continue;
114
+ const collectIslandsInDir = async (dir: string, routeId: string, priority: IslandFileEntry["priority"]) => {
115
+ if (seenDirs.has(dir)) return;
94
116
  seenDirs.add(dir);
95
-
96
117
  let files: string[];
97
- try { files = await fs.readdir(dir); } catch { continue; }
98
-
99
- const priority = getRouteHydration(route)?.priority || HYDRATION.DEFAULT_PRIORITY;
118
+ try { files = await fs.readdir(dir); } catch { return; }
100
119
  for (const file of files) {
101
120
  if (/\.island\.tsx?$/.test(file)) {
102
121
  entries.push({
103
122
  name: file.replace(/\.island\.tsx?$/, ""),
104
123
  filePath: path.join(dir, file),
105
- routeId: route.id,
124
+ routeId,
106
125
  priority,
107
126
  });
108
127
  }
109
128
  }
129
+ };
130
+
131
+ for (const route of routes) {
132
+ // Defensive guard — see the block comment above for rationale. Without
133
+ // this, a hypothetical caller passing `manifest.routes` directly would
134
+ // readdir every page route, including pure-SSR ones.
135
+ if (!needsHydration(route)) continue;
136
+
137
+ const dir = path.dirname(path.join(rootDir, route.componentModule ?? route.module));
138
+ const priority = getRouteHydration(route)?.priority || HYDRATION.DEFAULT_PRIORITY;
139
+
140
+ await collectIslandsInDir(dir, route.id, priority);
141
+ for (const sub of ISLAND_SUBDIRS) {
142
+ await collectIslandsInDir(path.join(dir, sub), route.id, priority);
143
+ }
110
144
  }
111
145
  return entries;
112
146
  }
@@ -130,6 +164,62 @@ export const _testOnly_scanIslandFiles = scanIslandFiles;
130
164
  */
131
165
  export const _testOnly_getHydratedRoutes = getHydratedRoutes;
132
166
 
167
+ /**
168
+ * Issue #240 Phase 2 — collect every file the React Compiler plugin
169
+ * would touch during a client build. Used by `mandu check` to feed
170
+ * `runReactCompilerLint` so developers see which components are
171
+ * silently bailing out.
172
+ *
173
+ * Included:
174
+ * - `*.island.tsx` / `*.island.ts` under each hydrated route dir
175
+ * (+ `_components/` / `_islands/` siblings — matches
176
+ * `scanIslandFiles` exactly).
177
+ * - `route.clientModule` for every hydrated route (`"use client"`
178
+ * page entry).
179
+ * - `*.partial.ts` / `*.partial.tsx` anywhere under `rootDir`.
180
+ *
181
+ * Paths are absolute and deduplicated. Returns `[]` when the manifest
182
+ * has no hydrated routes.
183
+ */
184
+ export async function collectCompilerLintTargets(
185
+ manifest: RoutesManifest,
186
+ rootDir: string,
187
+ ): Promise<string[]> {
188
+ const hydratedRoutes = getHydratedRoutes(manifest);
189
+ if (hydratedRoutes.length === 0) return [];
190
+
191
+ const out = new Set<string>();
192
+
193
+ // 1) Islands (reuses the bundler's canonical scanner).
194
+ const islandFiles = await scanIslandFiles(hydratedRoutes, rootDir);
195
+ for (const entry of islandFiles) {
196
+ out.add(path.resolve(entry.filePath));
197
+ }
198
+
199
+ // 2) `"use client"` page modules — the hydrated-route filter
200
+ // already asserted `clientModule` exists.
201
+ for (const route of hydratedRoutes) {
202
+ const rel = route.clientModule ?? route.module;
203
+ if (rel) out.add(path.resolve(rootDir, rel));
204
+ }
205
+
206
+ // 3) Partials — glob the whole project once. Partials live anywhere
207
+ // the user chooses, they're not sibling-scoped like islands.
208
+ try {
209
+ const glob = new Bun.Glob("**/*.partial.{ts,tsx}");
210
+ for await (const rel of glob.scan({ cwd: rootDir, absolute: true })) {
211
+ if (rel.includes(`${path.sep}node_modules${path.sep}`)) continue;
212
+ if (rel.includes(`${path.sep}.mandu${path.sep}`)) continue;
213
+ out.add(path.resolve(rel));
214
+ }
215
+ } catch {
216
+ // Bun.Glob unavailable or scan failed — skip partials rather than
217
+ // aborting the whole diagnostic run.
218
+ }
219
+
220
+ return Array.from(out).sort();
221
+ }
222
+
133
223
  /** Build a single per-island bundle. */
134
224
  async function buildPerIslandBundle(
135
225
  entry: IslandFileEntry, outDir: string, options: BundlerOptions
@@ -150,7 +240,7 @@ async function buildPerIslandBundle(
150
240
  sourcemap: options.sourcemap ? "external" : "none",
151
241
  target: "browser",
152
242
  ...(isDev ? { reactFastRefresh: true } : {}),
153
- plugins: [...manduDefaultPlugins(options), ...(isDev ? [fastRefreshPlugin()] : [])],
243
+ plugins: [...manduClientPlugins(options), ...(isDev ? [fastRefreshPlugin()] : [])],
154
244
  external: ["react", "react-dom", "react-dom/client", ...(options.external || [])],
155
245
  define: { "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"), ...options.define },
156
246
  });
@@ -1623,7 +1713,7 @@ async function buildIsland(
1623
1713
  target: "browser",
1624
1714
  splitting: options.splitting ?? (process.env.NODE_ENV === "production"),
1625
1715
  ...(isDev ? { reactFastRefresh: true } : {}),
1626
- plugins: [...manduDefaultPlugins(options), ...(isDev ? [fastRefreshPlugin()] : [])],
1716
+ plugins: [...manduClientPlugins(options), ...(isDev ? [fastRefreshPlugin()] : [])],
1627
1717
  external: ["react", "react-dom", "react-dom/client", ...(options.external || [])],
1628
1718
  define: {
1629
1719
  "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"),
@@ -80,6 +80,31 @@ export interface DevBundlerOptions {
80
80
  * `"resource-regen"` exactly once.
81
81
  */
82
82
  onResourceChange?: (filePath: string) => void | Promise<void>;
83
+ /**
84
+ * Issue #242 — content collection change callback.
85
+ *
86
+ * Fires when an MDX/MD/YAML/JSON file under `content/` is added,
87
+ * edited, or removed. Before invoking the callback the bundler has
88
+ * already called `invalidateAllCollections()` so the Collection API's
89
+ * in-memory cache is clear; the next `.all()` / `.get()` call will
90
+ * rescan disk.
91
+ *
92
+ * The CLI typically wires this to the HMR server's `broadcast` to
93
+ * trigger a full browser reload so sidebars / route trees pick up the
94
+ * added file. Without a callback the invalidation still happens —
95
+ * a manual refresh will surface the change.
96
+ */
97
+ onContentChange?: (filePath: string) => void | Promise<void>;
98
+ /**
99
+ * Issue #240 — React Compiler opt-in. Forwarded verbatim to each
100
+ * `buildClientBundles()` invocation (initial build + every rebuild
101
+ * triggered by `handleFileChange`). Off by default. SSR paths are
102
+ * never affected regardless of this flag.
103
+ */
104
+ reactCompiler?: {
105
+ enabled?: boolean;
106
+ compilerConfig?: Record<string, unknown>;
107
+ };
83
108
  /**
84
109
  * Phase 7.0 R2 Agent D — package.json change notification.
85
110
  *
@@ -375,7 +400,9 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
375
400
  onAPIChange,
376
401
  onConfigReload,
377
402
  onResourceChange,
403
+ onContentChange,
378
404
  onPackageJsonChange,
405
+ reactCompiler,
379
406
  watchDirs: customWatchDirs = [],
380
407
  disableDefaultWatchDirs = false,
381
408
  } = options;
@@ -385,6 +412,7 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
385
412
  const initialBuild = await buildClientBundles(manifest, rootDir, {
386
413
  minify: false,
387
414
  sourcemap: true,
415
+ reactCompiler,
388
416
  });
389
417
 
390
418
  if (initialBuild.success) {
@@ -566,6 +594,21 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
566
594
  // layer simply never hit this path.
567
595
  }
568
596
 
597
+ // Issue #242 — content collections (MDX/MD/YAML/JSON) directory. The
598
+ // Collection API caches scan results in-process, so adding a new
599
+ // `content/docs/**/foo.mdx` at runtime is invisible until we invalidate
600
+ // the cache. The `content/` directory is the conventional root used by
601
+ // `defineCollection({ path: "content/..." })`; projects that put
602
+ // collection roots elsewhere simply won't match and fall back to a full
603
+ // manual restart, which is the prior behaviour.
604
+ const contentDir = path.join(rootDir, "content");
605
+ try {
606
+ await fs.promises.access(contentDir);
607
+ watchDirs.add(contentDir);
608
+ } catch {
609
+ // No content/ directory — most projects without docs / blogs.
610
+ }
611
+
569
612
  // 공통 컴포넌트 디렉토리 추가 (기본 + 커스텀)
570
613
  const commonDirsToCheck = disableDefaultWatchDirs
571
614
  ? customWatchDirs
@@ -761,6 +804,11 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
761
804
  // anyway).
762
805
  let hasConfigReload = false;
763
806
  let hasResourceRegen = false;
807
+ // Issue #242 — content collection edits (MDX/MD/YAML/JSON under
808
+ // `content/`). Caught here so a new file triggers `invalidateAll` +
809
+ // full-reload instead of falling through to "unknown" and never
810
+ // reaching the Collection cache.
811
+ let hasContent = false;
764
812
 
765
813
  for (const file of files) {
766
814
  const normalized = normalizeFsPath(file);
@@ -781,6 +829,18 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
781
829
  continue;
782
830
  }
783
831
 
832
+ // Issue #242 — content-collection sources. Anything under the
833
+ // project's `content/` directory that's plausibly an entry file.
834
+ // The Collection API accepts `.md`/`.mdx` for MDX entries and
835
+ // `.json`/`.yaml`/`.yml` for data collections — cover all four.
836
+ if (
837
+ normalized.includes("/content/") &&
838
+ /\.(mdx?|ya?ml|json)$/.test(normalized)
839
+ ) {
840
+ hasContent = true;
841
+ continue;
842
+ }
843
+
784
844
  if (normalized.endsWith(".css")) {
785
845
  hasCss = true;
786
846
  continue;
@@ -831,6 +891,12 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
831
891
  // would cause a stale rebuild.
832
892
  if (hasConfigReload) return "config-reload";
833
893
  if (hasResourceRegen) return "resource-regen";
894
+ // Issue #242 — content changes are a leaf category: they never
895
+ // produce code changes, only cache invalidation + a browser refresh.
896
+ // Rank them above common-dir so a mixed batch that touches both
897
+ // still reaches the content path (otherwise common-dir would
898
+ // rebuild but the collection cache would stay stale).
899
+ if (hasContent) return "content-change";
834
900
 
835
901
  // Common-dir dominates — it already fans out to every island + SSR
836
902
  // registry. No point in double-classifying "mixed" when a fan-out fix
@@ -885,6 +951,17 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
885
951
  return;
886
952
  }
887
953
 
954
+ // Issue #242 — content collection changes. Invalidate the shared
955
+ // Collection cache and notify the optional callback; no rebuild
956
+ // required. One invocation per debounce window.
957
+ if (kind === "content-change") {
958
+ const contentFiles = files.filter((f) =>
959
+ /\.(mdx?|ya?ml|json)$/.test(normalizeFsPath(f)),
960
+ );
961
+ await handleContentChange(contentFiles);
962
+ return;
963
+ }
964
+
888
965
  // Common-dir dominates: one full-reload-adjacent rebuild covers everyone.
889
966
  if (kind === "common-dir") {
890
967
  await handleFileChange(files.find((f) => isInCommonDir(f)) ?? files[0]);
@@ -996,6 +1073,44 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
996
1073
  }
997
1074
  };
998
1075
 
1076
+ /**
1077
+ * Issue #242 — dispatch a batch of content collection changes.
1078
+ *
1079
+ * Clears every registered `Collection`'s in-memory cache via
1080
+ * `invalidateAllCollections()` and then invokes `onContentChange` once
1081
+ * per file so the CLI can broadcast a full browser reload. Dynamic
1082
+ * import keeps the bundler module tree free of a hard content-layer
1083
+ * dependency (projects without `@mandujs/core/content` in the import
1084
+ * closure simply get a no-op).
1085
+ */
1086
+ const handleContentChange = async (files: readonly string[]): Promise<void> => {
1087
+ if (files.length === 0) return;
1088
+ try {
1089
+ const mod: typeof import("../content/collection") = await import("../content/collection");
1090
+ if (typeof mod.invalidateAllCollections === "function") {
1091
+ mod.invalidateAllCollections();
1092
+ }
1093
+ } catch {
1094
+ // Content layer not in the closure — harmless.
1095
+ }
1096
+ if (onContentChange) {
1097
+ for (const file of files) {
1098
+ try {
1099
+ await Promise.resolve(onContentChange(file));
1100
+ } catch (err) {
1101
+ console.error(
1102
+ `[Mandu HMR] content-change callback threw for ${path.basename(file)}:`,
1103
+ err instanceof Error ? err.message : String(err),
1104
+ );
1105
+ }
1106
+ }
1107
+ } else {
1108
+ console.log(
1109
+ `[Mandu HMR] ${files.length} content file(s) changed — collection cache invalidated`,
1110
+ );
1111
+ }
1112
+ };
1113
+
999
1114
  const handleFileChange = async (changedFile: string): Promise<void> => {
1000
1115
  // 동시 빌드 방지 (#121) — B2 강화: 빌드 중이면 Set에 추가 (drop 방지).
1001
1116
  if (isBuilding) {
@@ -1020,6 +1135,13 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
1020
1135
  await handleResourceRegenBatch([changedFile]);
1021
1136
  return;
1022
1137
  }
1138
+ if (
1139
+ normalized.includes("/content/") &&
1140
+ /\.(mdx?|ya?ml|json)$/.test(normalized)
1141
+ ) {
1142
+ await handleContentChange([changedFile]);
1143
+ return;
1144
+ }
1023
1145
  if (isPackageJsonFile(normalized)) {
1024
1146
  // Advisory notification only — a `package.json` save on npm install
1025
1147
  // fires multiple times in <100 ms, so auto-restart would loop. The
@@ -1202,6 +1324,7 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
1202
1324
  minify: false,
1203
1325
  sourcemap: true,
1204
1326
  targetRouteIds: targetIds,
1327
+ reactCompiler,
1205
1328
  });
1206
1329
  const buildTime = performance.now() - startTime;
1207
1330
  if (result.success) {
@@ -1259,6 +1382,7 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
1259
1382
  minify: false,
1260
1383
  sourcemap: true,
1261
1384
  skipFrameworkBundles: true,
1385
+ reactCompiler,
1262
1386
  });
1263
1387
 
1264
1388
  const buildTime = performance.now() - startTime;
@@ -1383,6 +1507,7 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
1383
1507
  minify: false,
1384
1508
  sourcemap: true,
1385
1509
  targetRouteIds: [routeId],
1510
+ reactCompiler,
1386
1511
  });
1387
1512
 
1388
1513
  const buildTime = performance.now() - startTime;
@@ -237,6 +237,7 @@ export interface CoalescedChange {
237
237
  | "api-only" // route.ts (API)
238
238
  | "config-reload" // mandu.config.ts / .env — server restart
239
239
  | "resource-regen" // *.resource.ts / *.contract.ts — code-gen
240
+ | "content-change" // content/**/*.{mdx,md,json,yaml} — collection invalidate + full-reload
240
241
  | "mixed"; // more than one category
241
242
  }
242
243
 
@@ -0,0 +1,110 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import path from "node:path";
3
+ import {
4
+ runReactCompilerLint,
5
+ formatCompilerReport,
6
+ type ReactCompilerDiagnostic,
7
+ } from "../react-compiler-lint";
8
+
9
+ describe("runReactCompilerLint", () => {
10
+ test("returns empty result with no targets without invoking ESLint", async () => {
11
+ const result = await runReactCompilerLint({
12
+ projectRoot: process.cwd(),
13
+ targetFiles: [],
14
+ });
15
+ expect(result.diagnostics).toEqual([]);
16
+ expect(result.skipped).toBe(false);
17
+ });
18
+
19
+ test("degrades gracefully when ESLint peer dep is missing", async () => {
20
+ // In a clean Mandu checkout `eslint` and
21
+ // `eslint-plugin-react-compiler` are not installed. The runner
22
+ // must report `skipped: true` + empty diagnostics + a skipReason.
23
+ const result = await runReactCompilerLint({
24
+ projectRoot: process.cwd(),
25
+ targetFiles: [path.resolve(__filename)],
26
+ });
27
+ // If ESLint happens to be installed in this env, diagnostics is
28
+ // either empty (this file has no bailouts) or the runner ran
29
+ // successfully. Either shape is valid — the contract we care
30
+ // about is "no throw + typed result."
31
+ expect(Array.isArray(result.diagnostics)).toBe(true);
32
+ expect(typeof result.skipped).toBe("boolean");
33
+ if (result.skipped) {
34
+ expect(typeof result.skipReason).toBe("string");
35
+ }
36
+ });
37
+ });
38
+
39
+ describe("formatCompilerReport", () => {
40
+ test("returns the no-bailouts sentinel for empty input", () => {
41
+ expect(formatCompilerReport([])).toContain("no bailouts detected");
42
+ });
43
+
44
+ test("summarises bailouts with file counts + bullet list", () => {
45
+ const diagnostics: ReactCompilerDiagnostic[] = [
46
+ {
47
+ file: "/app/todos/todo.island.tsx",
48
+ line: 42,
49
+ column: 5,
50
+ message: "Cannot memoize: ref escape in closure.",
51
+ ruleId: "react-compiler/react-compiler",
52
+ severity: "warning",
53
+ },
54
+ {
55
+ file: "/app/todos/todo.island.tsx",
56
+ line: 87,
57
+ column: 3,
58
+ message: "Cannot memoize: conditional hook call.",
59
+ ruleId: "react-compiler/react-compiler",
60
+ severity: "warning",
61
+ },
62
+ {
63
+ file: "/app/dashboard/chart.client.tsx",
64
+ line: 14,
65
+ column: 9,
66
+ message: "Cannot memoize: mutation of a shared value.",
67
+ ruleId: "react-compiler/react-compiler",
68
+ severity: "warning",
69
+ },
70
+ ];
71
+ const out = formatCompilerReport(diagnostics);
72
+ expect(out).toContain("3 bailout(s) in 2 file(s)");
73
+ expect(out).toContain("todo.island.tsx:42:5");
74
+ expect(out).toContain("chart.client.tsx:14:9");
75
+ expect(out).toContain("https://react.dev");
76
+ });
77
+
78
+ test("truncates past the limit with a '... N more' tail", () => {
79
+ const diagnostics: ReactCompilerDiagnostic[] = Array.from(
80
+ { length: 30 },
81
+ (_, i) => ({
82
+ file: `/app/x/c${i}.island.tsx`,
83
+ line: i + 1,
84
+ column: 1,
85
+ message: "bailout",
86
+ ruleId: "react-compiler/react-compiler",
87
+ severity: "warning" as const,
88
+ }),
89
+ );
90
+ const out = formatCompilerReport(diagnostics, { limit: 5 });
91
+ expect(out).toContain("30 bailout(s)");
92
+ expect(out).toContain("… and 25 more");
93
+ });
94
+
95
+ test("uses relative paths when projectRoot is provided", () => {
96
+ const diagnostic: ReactCompilerDiagnostic = {
97
+ file: path.resolve("/project/app/todo.island.tsx"),
98
+ line: 1,
99
+ column: 1,
100
+ message: "bailout",
101
+ ruleId: "react-compiler/react-compiler",
102
+ severity: "warning",
103
+ };
104
+ const out = formatCompilerReport([diagnostic], {
105
+ projectRoot: path.resolve("/project"),
106
+ });
107
+ expect(out).toContain(path.join("app", "todo.island.tsx"));
108
+ expect(out).not.toContain(path.resolve("/project/app"));
109
+ });
110
+ });
@@ -22,6 +22,20 @@ export {
22
22
  type BlockGeneratedImportsOptions,
23
23
  } from "./block-generated-imports";
24
24
 
25
+ export {
26
+ reactCompiler,
27
+ type ReactCompilerPluginOptions,
28
+ } from "./react-compiler";
29
+
30
+ export {
31
+ runReactCompilerLint,
32
+ formatCompilerReport,
33
+ type ReactCompilerDiagnostic,
34
+ type RunReactCompilerLintOptions,
35
+ type RunReactCompilerLintResult,
36
+ type FormatCompilerReportOptions,
37
+ } from "./react-compiler-lint";
38
+
25
39
  /**
26
40
  * Subset of `ManduConfig.guard` consumed by `defaultBundlerPlugins()`.
27
41
  * We deliberately don't import the full `ManduConfig` type to keep the